Compare commits
145
Commits
50f0b8ade9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9076f8ddb0 | ||
|
|
8c99dc015a | ||
|
|
60ee157e97 | ||
|
|
dd8df33910 | ||
|
|
5458f3b8a4 | ||
|
|
44e1a6651a | ||
|
|
5b8daab457 | ||
|
|
697e54427b | ||
|
|
fdaca7539b | ||
|
|
3c9e65c78e | ||
|
|
71fd182f50 | ||
|
|
a315c62f3a | ||
|
|
050d1329c4 | ||
|
|
433ab41f95 | ||
|
|
70e0624a47 | ||
|
|
e69cea0245 | ||
|
|
6d9237a592 | ||
|
|
6241fb9d6d | ||
|
|
2d0c740c88 | ||
|
|
ce64c4eb47 | ||
|
|
ce2469616e | ||
|
|
dcb4798408 | ||
|
|
ab15218b27 | ||
|
|
b0932dd398 | ||
|
|
9e3007e7f6 | ||
|
|
98089b6748 | ||
|
|
b73db01626 | ||
|
|
cf8affe4d7 | ||
|
|
61c923b92b | ||
|
|
c7faee8da3 | ||
|
|
29b9a8c39a | ||
|
|
d1b0fffabb | ||
|
|
47eac0f2a7 | ||
|
|
2f8f4a36c6 | ||
|
|
28dccb908c | ||
|
|
3b62195a36 | ||
|
|
358f4623ba | ||
|
|
240b7be7b8 | ||
|
|
9a17297f1e | ||
|
|
059fb272bb | ||
|
|
41006b098a | ||
|
|
f64f98834f | ||
|
|
9da73cc6e3 | ||
|
|
5a93651db7 | ||
|
|
bd6b7e64e2 | ||
|
|
dda70940c0 | ||
|
|
865c163683 | ||
|
|
85f0d6e188 | ||
|
|
fd6365cf6e | ||
|
|
d8e7be4ccb | ||
|
|
c4ee4279ef | ||
|
|
1dd24825ca | ||
|
|
18f053e64b | ||
|
|
8a73edf4db | ||
|
|
c25ccb491d | ||
|
|
ff91bd4f70 | ||
|
|
7d79f677fe | ||
|
|
4d04d7ac83 | ||
|
|
6776ac2f2d | ||
|
|
d3024b4810 | ||
|
|
a58ded055e | ||
|
|
557c8a25ba | ||
|
|
d69be48f96 | ||
|
|
8a0d48eada | ||
|
|
361dada276 | ||
|
|
19c6669800 | ||
|
|
7c6cdc5b67 | ||
|
|
88189770ff | ||
|
|
947434b65a | ||
|
|
a25542694d | ||
|
|
17a709c92f | ||
|
|
4046c9452d | ||
|
|
7f439f0db5 | ||
|
|
c50ce1faec | ||
|
|
701983ccdd | ||
|
|
8162509158 | ||
|
|
367fd0d412 | ||
|
|
24992fc9fb | ||
|
|
a5aeb8889c | ||
|
|
96e403ee47 | ||
|
|
77d4813bb2 | ||
|
|
ccde30e378 | ||
|
|
8d5b02e37f | ||
|
|
778af2da53 | ||
|
|
f8054cb58c | ||
|
|
fd0ea3a066 | ||
|
|
8300c3b6b7 | ||
|
|
e00c50e703 | ||
|
|
0d944ec97d | ||
|
|
a8ba2312fc | ||
|
|
cbfdb2ae39 | ||
|
|
52e442790a | ||
|
|
8baf3a6a8b | ||
|
|
dd03abbbb0 | ||
|
|
e5a12f9b74 | ||
|
|
56f3f96363 | ||
|
|
8e37b965aa | ||
|
|
c01dd4c6b2 | ||
|
|
9669b52498 | ||
|
|
fa10cccf71 | ||
|
|
989fdbf878 | ||
|
|
30f09b6268 | ||
|
|
6e511111c4 | ||
|
|
25ebc10a8a | ||
|
|
ecb1dba9ff | ||
|
|
a883903b66 | ||
|
|
a46f7d02a1 | ||
|
|
fb09924835 | ||
|
|
bb6f24bbcb | ||
|
|
08cef7ca3c | ||
|
|
82567fd248 | ||
|
|
d00ada67a5 | ||
|
|
99bde4febb | ||
|
|
9afbdc91fa | ||
|
|
c0a653fa3f | ||
|
|
8381b3068a | ||
|
|
ec261d57c2 | ||
|
|
c049c3c1b1 | ||
|
|
bead6e58ac | ||
|
|
75879c8a52 | ||
|
|
03c7c30067 | ||
|
|
9250254dec | ||
|
|
8b7e5a2800 | ||
|
|
a166ffd8a4 | ||
|
|
b613a315ff | ||
|
|
5b64efab53 | ||
|
|
40efa53411 | ||
|
|
b4803f90e6 | ||
|
|
2cd314b183 | ||
|
|
a3ba94be04 | ||
|
|
a68f609453 | ||
|
|
1107ace89c | ||
|
|
78ce788e2f | ||
|
|
938d97a2ad | ||
|
|
c162c2501b | ||
|
|
efeb3eb926 | ||
|
|
a2802c6320 | ||
|
|
c72c31de04 | ||
|
|
cdef630e67 | ||
|
|
035b177128 | ||
|
|
8f74eac50c | ||
|
|
9f4641e4fc | ||
|
|
97f23be394 | ||
|
|
e5ea32a44a | ||
|
|
8507a5a508 |
@@ -7,6 +7,5 @@ __pycache__
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
tasks
|
tasks
|
||||||
console/node_modules
|
|
||||||
cloud-console/node_modules
|
cloud-console/node_modules
|
||||||
cloud-console/dist
|
cloud-console/dist
|
||||||
|
|||||||
+14
-24
@@ -8,29 +8,19 @@ POSTGRES_USER=device_cloud
|
|||||||
POSTGRES_PASSWORD=change-me-database-password
|
POSTGRES_PASSWORD=change-me-database-password
|
||||||
|
|
||||||
CLOUD_API_PORT=8001
|
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
|
# Fernet key protecting database-managed LLM Provider API keys. Generate once
|
||||||
HOST_AGENT_TOKEN=change-me-host-token
|
# and preserve it with the database backups (rotating it makes stored keys
|
||||||
HOST_AGENT_ENROLLMENT_TOKEN=
|
# undecryptable):
|
||||||
HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
HOST_AGENT_DISPLAY_NAME=
|
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY=change-me-generate-a-fernet-key
|
||||||
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
|
|
||||||
|
|
||||||
AI_PLANNER_ENABLED=false
|
# Cloud-proxy planner budget reservations. These apply only to Hosts reporting
|
||||||
AI_PLANNER_PROVIDER=anthropic
|
# AI_PLANNER_TRANSPORT=cloud.
|
||||||
AI_PLANNER_MODEL=
|
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING=4096
|
||||||
AI_PLANNER_TIMEOUT_SECONDS=30
|
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS=300
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
OPENAI_API_KEY=
|
# 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
|
||||||
|
|||||||
@@ -12,3 +12,7 @@
|
|||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
*.egg-info/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
+10
-13
@@ -1,21 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# Build args let CI inject mirrors for faster builds in CN networks; the
|
# Base images are pinned to the project's registry mirrors so the build is
|
||||||
# defaults keep the Dockerfile portable so anyone can `docker build .`
|
# deterministic and avoids Docker Hub / ghcr.io pull failures in CN networks.
|
||||||
# without extra configuration.
|
# 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` (build-arg)
|
||||||
# NPM_REGISTRY – npm registry for `npm ci`
|
# APT_MIRROR – Debian apt mirror host (build-arg, empty = official)
|
||||||
# UV_IMAGE – stage 2 base (ghcr.io/astral-sh/uv)
|
# UV_INDEX_URL – PyPI index URL passed through to `uv sync` (build-arg, empty = official)
|
||||||
# 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
|
|
||||||
|
|
||||||
# Stage 1: build the cloud-console Vue 3 SPA.
|
# Stage 1: build the cloud-console Vue 3 SPA.
|
||||||
# NOTE: do not set NODE_ENV=production here — vue-tsc and typescript are
|
# NOTE: do not set NODE_ENV=production here — vue-tsc and typescript are
|
||||||
# devDependencies required by `npm run build`.
|
# devDependencies required by `npm run build`.
|
||||||
ARG NODE_IMAGE=node:20-bookworm-slim
|
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
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)
|
# 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.
|
# are visible to RUN. Without this, ARGs declared before FROM are inaccessible.
|
||||||
ARG NPM_REGISTRY
|
ARG NPM_REGISTRY
|
||||||
@@ -26,8 +23,7 @@ COPY cloud-console/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: the existing Python image, now carrying the SPA build output.
|
# 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 registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim
|
||||||
FROM ${UV_IMAGE}
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -58,6 +54,7 @@ ARG UV_INDEX_URL=
|
|||||||
ENV UV_INDEX_URL=${UV_INDEX_URL}
|
ENV UV_INDEX_URL=${UV_INDEX_URL}
|
||||||
RUN uv sync --locked --all-packages --no-dev
|
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"]
|
CMD ["device-cloud-api", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
|
|||||||
Vendored
+8
-9
@@ -22,13 +22,12 @@ pipeline {
|
|||||||
booleanParam(name: 'PUSH', defaultValue: true, description: 'Push the image to REGISTRY after a successful build')
|
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
|
// Mirror overrides passed to `docker build` as --build-arg. Defaults target
|
||||||
// CN networks so Jenkins builds don't time out pulling from Docker Hub /
|
// CN networks so Jenkins builds don't time out pulling from npmjs.org /
|
||||||
// ghcr.io / npmjs.org / deb.debian.org. Override or blank any of these to
|
// deb.debian.org / PyPI. Override or blank any of these to build against
|
||||||
// build against the official upstreams.
|
// the official upstreams. Base images (node, uv) are pinned in the
|
||||||
string(name: 'NODE_IMAGE', defaultValue: 'registry.jerryyan.net/library/node:20-bookworm-slim', description: 'Stage 1 base image (Docker Hub library/node proxy)')
|
// 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: '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')
|
||||||
string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
@@ -56,7 +55,7 @@ pipeline {
|
|||||||
when { expression { return !params.SKIP_TESTS } }
|
when { expression { return !params.SKIP_TESTS } }
|
||||||
agent {
|
agent {
|
||||||
docker {
|
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
|
reuseNode true
|
||||||
// The Docker Pipeline plugin runs the container as the Jenkins host
|
// The Docker Pipeline plugin runs the container as the Jenkins host
|
||||||
// user (non-root), so /root isn't writable. Cache under /tmp, which is
|
// 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
|
// Pass every mirror override through as --build-arg. Empty values
|
||||||
// are skipped so the Dockerfile ARG default applies.
|
// are skipped so the Dockerfile ARG default applies.
|
||||||
def buildArgs = []
|
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()
|
def v = params[name]?.toString()?.trim()
|
||||||
if (v) {
|
if (v) {
|
||||||
buildArgs << "--build-arg ${name}=${v}"
|
buildArgs << "--build-arg ${name}=${v}"
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ contracts.
|
|||||||
- `driver/`: the `Driver` contract, concrete driver adapters, and driver-type
|
- `driver/`: the `Driver` contract, concrete driver adapters, and driver-type
|
||||||
registry.
|
registry.
|
||||||
- `device/`: device lifecycle and active driver management.
|
- `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`.
|
- `perception/`: screen-to-`Scene` perception behind `PerceptionProvider`.
|
||||||
- `runtime/`: planning and execution orchestration.
|
- `runtime/`: planning and execution orchestration.
|
||||||
- `api/`: REST/MCP transport adapters.
|
- `api/`: MCP and supporting integration adapters.
|
||||||
- `storage/`: timeline, task, and device configuration persistence.
|
- `storage/`: timeline, task, and device configuration persistence.
|
||||||
- `packages/cloud-platform/`: cloud scheduling, device pooling, plugins, and
|
- `packages/cloud-platform/`: cloud scheduling, device pooling, plugins, and
|
||||||
the Python cloud SDK as the `device-cloud-platform` workspace member.
|
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
|
uv build --package device-cloud-platform
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vue/Vite application under `console/` remains an independent npm project;
|
Runtime is an in-process execution library, not a standalone HTTP service. The
|
||||||
uv does not install or modify its JavaScript dependencies.
|
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
|
## Project Direction
|
||||||
|
|
||||||
@@ -69,5 +149,5 @@ invariants future changes must preserve are in
|
|||||||
or deployed PostgreSQL, configure credentials and Runtime AI planning, and
|
or deployed PostgreSQL, configure credentials and Runtime AI planning, and
|
||||||
perform orderly shutdown or rollback.
|
perform orderly shutdown or rollback.
|
||||||
- [macOS migration and real iPhone setup](docs/MACOS_IPHONE_SETUP.md): install
|
- [macOS migration and real iPhone setup](docs/MACOS_IPHONE_SETUP.md): install
|
||||||
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and start a
|
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and run a
|
||||||
connected Runtime API.
|
connected Host Agent.
|
||||||
|
|||||||
+27
-4
@@ -4,7 +4,6 @@ import logging
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from agents.config import CollaborationConfig, load_config
|
from agents.config import CollaborationConfig, load_config
|
||||||
from agents.models import Observation, VerificationVerdict
|
|
||||||
from agents.observer import Observer
|
from agents.observer import Observer
|
||||||
from agents.reflector import Reflector
|
from agents.reflector import Reflector
|
||||||
from agents.verifier import Verifier
|
from agents.verifier import Verifier
|
||||||
@@ -21,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CollaborativeTaskRunnerConfig:
|
class CollaborativeTaskRunnerConfig:
|
||||||
max_steps: int = 20
|
max_steps: int = 999999
|
||||||
max_recovery_attempts: int = 3
|
max_recovery_attempts: int = 3
|
||||||
|
|
||||||
|
|
||||||
@@ -94,10 +93,21 @@ class CollaborativeTaskRunner:
|
|||||||
|
|
||||||
for step in steps:
|
for step in steps:
|
||||||
executable_step = self._step_for_device(step, task.device_id)
|
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)
|
result = self.executor.execute(executable_step, context=context)
|
||||||
|
after_screenshot = self.task_runner._planning_screenshot(task.device_id)
|
||||||
context.add_step_result(result)
|
context.add_step_result(result)
|
||||||
self.task_runner._record_step_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)
|
post_scene = self._observe_scene(task.device_id)
|
||||||
@@ -146,13 +156,26 @@ class CollaborativeTaskRunner:
|
|||||||
description=outcome.action.description,
|
description=outcome.action.description,
|
||||||
args=outcome.action.args,
|
args=outcome.action.args,
|
||||||
)
|
)
|
||||||
|
before_screenshot = self.task_runner._planning_screenshot(
|
||||||
|
task.device_id
|
||||||
|
)
|
||||||
recovery_result = self.executor.execute(
|
recovery_result = self.executor.execute(
|
||||||
self._step_for_device(recovery_step, task.device_id),
|
self._step_for_device(recovery_step, task.device_id),
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
after_screenshot = self.task_runner._planning_screenshot(
|
||||||
|
task.device_id
|
||||||
|
)
|
||||||
context.add_step_result(recovery_result)
|
context.add_step_result(recovery_result)
|
||||||
self.task_runner._record_step_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:
|
if not recovery_result.success:
|
||||||
return self._fail(
|
return self._fail(
|
||||||
|
|||||||
-148
@@ -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
@@ -3,7 +3,7 @@ from collections.abc import Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.errors import call_with_semantic_errors
|
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.describe_screen import describe_screen
|
||||||
from tools.find_icon import find_icon_on_screen
|
from tools.find_icon import find_icon_on_screen
|
||||||
from tools.find_text import find_text_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(
|
def tool_handlers(
|
||||||
*,
|
*,
|
||||||
manager: DeviceManager | None = None,
|
manager: DeviceManager,
|
||||||
) -> dict[str, Callable[..., Any]]:
|
) -> dict[str, Callable[..., Any]]:
|
||||||
device_manager = manager or DEFAULT_MANAGER
|
|
||||||
|
|
||||||
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"image_base64": base64.b64encode(image).decode("ascii"),
|
"image_base64": base64.b64encode(image).decode("ascii"),
|
||||||
@@ -39,70 +38,77 @@ def tool_handlers(
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
device_id=device_id,
|
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": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: (
|
||||||
swipe,
|
call_with_semantic_errors(
|
||||||
start_x,
|
swipe,
|
||||||
start_y,
|
start_x,
|
||||||
end_x,
|
start_y,
|
||||||
end_y,
|
end_x,
|
||||||
duration_ms=duration_ms,
|
end_y,
|
||||||
device_id=device_id,
|
duration_ms=duration_ms,
|
||||||
manager=device_manager,
|
device_id=device_id,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"input_text": lambda text, device_id=None: call_with_semantic_errors(
|
"input_text": lambda text, device_id=None: call_with_semantic_errors(
|
||||||
input_text,
|
input_text,
|
||||||
text,
|
text,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
|
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
|
||||||
launch_app,
|
launch_app,
|
||||||
app_id,
|
app_id,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"find_text": lambda query, device_id=None: call_with_semantic_errors(
|
"find_text": lambda query, device_id=None: call_with_semantic_errors(
|
||||||
find_text_on_screen,
|
find_text_on_screen,
|
||||||
query,
|
query,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
|
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
|
||||||
find_icon_on_screen,
|
find_icon_on_screen,
|
||||||
name,
|
name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
manager=device_manager,
|
manager=manager,
|
||||||
),
|
),
|
||||||
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
|
"get_ui_tree": lambda device_id=None, include_app_info=False: (
|
||||||
get_ui_tree,
|
call_with_semantic_errors(
|
||||||
device_id,
|
get_ui_tree,
|
||||||
manager=device_manager,
|
device_id,
|
||||||
|
manager=manager,
|
||||||
|
include_app_info=include_app_info,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"describe_screen": lambda device_id=None: call_with_semantic_errors(
|
"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: [
|
"list_devices": lambda: [device.to_dict() for device in manager.list_devices()],
|
||||||
device.to_dict() for device in device_manager.list_devices()
|
|
||||||
],
|
|
||||||
"device_status": lambda device_id: call_with_semantic_errors(
|
"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(
|
def create_mcp_server(
|
||||||
*,
|
*,
|
||||||
manager: DeviceManager | None = None,
|
manager: DeviceManager,
|
||||||
skill_catalog_store: Any | None = None,
|
skill_catalog_store: Any | None = None,
|
||||||
skill_active_subscriptions: set[str] | None = None,
|
skill_active_subscriptions: set[str] | None = None,
|
||||||
|
skill_local_store: Any | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError("mcp SDK is not installed") from 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)
|
handlers = tool_handlers(manager=manager)
|
||||||
server = FastMCP("apex-agent")
|
server = FastMCP("apex-agent")
|
||||||
|
|
||||||
@@ -149,8 +155,14 @@ def create_mcp_server(
|
|||||||
return handlers["find_icon"](name=name, device_id=device_id)
|
return handlers["find_icon"](name=name, device_id=device_id)
|
||||||
|
|
||||||
@server.tool(name="get_ui_tree")
|
@server.tool(name="get_ui_tree")
|
||||||
def _get_ui_tree(device_id: str | None = None) -> Any:
|
def _get_ui_tree(
|
||||||
return handlers["get_ui_tree"](device_id=device_id)
|
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")
|
@server.tool(name="describe_screen")
|
||||||
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
|
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:
|
if skill_catalog_store is not None:
|
||||||
from api.skill_catalog_mcp import register_skill_catalog_tools
|
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(
|
register_skill_catalog_tools(
|
||||||
server,
|
server,
|
||||||
store=skill_catalog_store,
|
store=skill_catalog_store,
|
||||||
|
local_store=local_store,
|
||||||
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
|
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
|
||||||
get_registered_tools=lambda: set(handlers.keys()),
|
get_registered_tools=lambda: set(handlers.keys()),
|
||||||
)
|
)
|
||||||
|
|||||||
-170
@@ -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
@@ -1,22 +1,31 @@
|
|||||||
"""MCP tool surface for the Skill Catalog.
|
"""MCP tool surface for the Skill Catalog.
|
||||||
|
|
||||||
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
|
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
|
||||||
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
|
Reads go through the unified merge surface in :mod:`api.skill_catalog_view`
|
||||||
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
|
(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
|
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
|
here but executed step-by-step by the LLM via the existing device-capability
|
||||||
tools (design D5).
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from skills_learning.models import (
|
from skills_learning.models import (
|
||||||
|
FlowStep,
|
||||||
FlowTemplateSkill,
|
FlowTemplateSkill,
|
||||||
KnowledgeSkill,
|
KnowledgeSkill,
|
||||||
SkillMetadata,
|
SkillMetadata,
|
||||||
)
|
)
|
||||||
|
from api.skill_catalog_view import SkillCatalogView, SkillSummary, SkillView
|
||||||
|
from storage.local_skills import LocalSkillStore
|
||||||
from storage.skill_catalog import SkillCatalogStore
|
from storage.skill_catalog import SkillCatalogStore
|
||||||
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
|
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
|
||||||
|
|
||||||
@@ -27,6 +36,9 @@ SKILL_TOOL_NAMES = (
|
|||||||
"search_skills",
|
"search_skills",
|
||||||
"get_skill",
|
"get_skill",
|
||||||
"resolve_flow_template",
|
"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."""
|
"""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(
|
def skill_tool_handlers(
|
||||||
*,
|
*,
|
||||||
store: SkillCatalogStore,
|
store: SkillCatalogStore,
|
||||||
|
local_store: LocalSkillStore,
|
||||||
get_active_subscriptions: Callable[[], set[str]],
|
get_active_subscriptions: Callable[[], set[str]],
|
||||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||||
) -> dict[str, Callable[..., dict[str, Any]]]:
|
) -> dict[str, Callable[..., dict[str, Any]]]:
|
||||||
@@ -62,44 +80,41 @@ def skill_tool_handlers(
|
|||||||
Decoupled from FastMCP so handlers can be tested directly without
|
Decoupled from FastMCP so handlers can be tested directly without
|
||||||
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
|
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())
|
tools_getter = get_registered_tools or (lambda: set())
|
||||||
|
|
||||||
def _list_skills() -> dict[str, Any]:
|
def _list_skills() -> dict[str, Any]:
|
||||||
metas = store.list_skills(get_active_subscriptions())
|
summaries = view.list_skills(get_active_subscriptions())
|
||||||
return {
|
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
|
||||||
"ok": True,
|
|
||||||
"skills": [_metadata_to_summary(m) for m in metas],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _search_skills(query: str) -> dict[str, Any]:
|
def _search_skills(query: str) -> dict[str, Any]:
|
||||||
metas = store.search_skills(query, get_active_subscriptions())
|
summaries = view.search_skills(query, get_active_subscriptions())
|
||||||
return {
|
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
|
||||||
"ok": True,
|
|
||||||
"skills": [_metadata_to_summary(m) for m in metas],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_skill(skill_id: str) -> dict[str, Any]:
|
def _get_skill(skill_id: str) -> dict[str, Any]:
|
||||||
skill = store.get_skill(
|
result = view.get_skill(
|
||||||
skill_id,
|
skill_id,
|
||||||
get_active_subscriptions(),
|
get_active_subscriptions(),
|
||||||
registered_tools=tools_getter(),
|
registered_tools=tools_getter(),
|
||||||
)
|
)
|
||||||
if skill is None:
|
if result is None:
|
||||||
return _error_response(SkillNotFoundError(skill_id))
|
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(
|
def _resolve_flow_template(
|
||||||
skill_id: str,
|
skill_id: str,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
params = params or {}
|
params = params or {}
|
||||||
skill = store.get_skill(
|
result = view.get_skill(
|
||||||
skill_id,
|
skill_id,
|
||||||
get_active_subscriptions(),
|
get_active_subscriptions(),
|
||||||
registered_tools=tools_getter(),
|
registered_tools=tools_getter(),
|
||||||
)
|
)
|
||||||
if skill is None:
|
if result is None:
|
||||||
return _error_response(SkillNotFoundError(skill_id))
|
return _error_response(SkillNotFoundError(skill_id))
|
||||||
|
skill = result.skill
|
||||||
if not isinstance(skill, FlowTemplateSkill):
|
if not isinstance(skill, FlowTemplateSkill):
|
||||||
return _error_response(
|
return _error_response(
|
||||||
InvalidFlowTemplateError(
|
InvalidFlowTemplateError(
|
||||||
@@ -112,11 +127,60 @@ def skill_tool_handlers(
|
|||||||
return _error_response(MissingParameterError(str(exc)))
|
return _error_response(MissingParameterError(str(exc)))
|
||||||
return {"ok": True, "steps": steps}
|
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 {
|
return {
|
||||||
"list_skills": _list_skills,
|
"list_skills": _list_skills,
|
||||||
"search_skills": _search_skills,
|
"search_skills": _search_skills,
|
||||||
"get_skill": _get_skill,
|
"get_skill": _get_skill,
|
||||||
"resolve_flow_template": _resolve_flow_template,
|
"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,
|
server: Any,
|
||||||
*,
|
*,
|
||||||
store: SkillCatalogStore,
|
store: SkillCatalogStore,
|
||||||
|
local_store: LocalSkillStore,
|
||||||
get_active_subscriptions: Callable[[], set[str]],
|
get_active_subscriptions: Callable[[], set[str]],
|
||||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Register ``list_skills``/``search_skills``/``get_skill``/
|
"""Register the skill MCP tools (read + authoring) on ``server``.
|
||||||
``resolve_flow_template`` as MCP tools on ``server``.
|
|
||||||
|
|
||||||
Returns the server so the caller can chain. No batch-execute tool is
|
Returns the server so the caller can chain. No batch-execute tool is
|
||||||
registered (design D5): the LLM issues each resulting device-capability
|
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(
|
handlers = skill_tool_handlers(
|
||||||
store=store,
|
store=store,
|
||||||
|
local_store=local_store,
|
||||||
get_active_subscriptions=get_active_subscriptions,
|
get_active_subscriptions=get_active_subscriptions,
|
||||||
get_registered_tools=get_registered_tools,
|
get_registered_tools=get_registered_tools,
|
||||||
)
|
)
|
||||||
@@ -159,25 +225,58 @@ def register_skill_catalog_tools(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
|
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
|
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 {
|
return {
|
||||||
"id": meta.id,
|
"id": meta.id,
|
||||||
"name": meta.name,
|
"name": meta.name,
|
||||||
"description": meta.description,
|
"description": meta.description,
|
||||||
"kind": meta.kind,
|
"kind": meta.kind,
|
||||||
"tags": list(meta.tags),
|
"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]:
|
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
|
||||||
"""Full skill payload for ``get_skill``."""
|
"""Full skill payload for ``get_skill`` / authoring responses."""
|
||||||
base = _metadata_to_summary(skill.metadata)
|
meta: SkillMetadata = skill.metadata
|
||||||
base["version"] = skill.metadata.version
|
base = {
|
||||||
base["updated_at"] = skill.metadata.updated_at.isoformat()
|
"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):
|
if isinstance(skill, KnowledgeSkill):
|
||||||
base["content"] = skill.content
|
base["content"] = skill.content
|
||||||
elif isinstance(skill, FlowTemplateSkill):
|
elif isinstance(skill, FlowTemplateSkill):
|
||||||
@@ -188,6 +287,46 @@ def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
|
|||||||
return base
|
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]:
|
def _error_response(exc: Exception) -> dict[str, Any]:
|
||||||
return {"ok": False, "error": _semantic_skill_error(exc)}
|
return {"ok": False, "error": _semantic_skill_error(exc)}
|
||||||
|
|
||||||
@@ -200,4 +339,7 @@ def _semantic_skill_error(exc: Exception) -> str:
|
|||||||
if isinstance(exc, MissingParameterError):
|
if isinstance(exc, MissingParameterError):
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
return f"missing parameter: {message}" if message else "invalid parameter"
|
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"
|
return "operation failed"
|
||||||
|
|||||||
@@ -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
@@ -7,6 +7,7 @@ truth" contract is enforced by that import boundary.
|
|||||||
|
|
||||||
Push (webhook) is optional; the baseline pull loop is correct standalone.
|
Push (webhook) is optional; the baseline pull loop is correct standalone.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -21,6 +22,7 @@ from skills_learning.models import (
|
|||||||
KnowledgeSkill,
|
KnowledgeSkill,
|
||||||
Skill,
|
Skill,
|
||||||
)
|
)
|
||||||
|
from storage.local_skills import LocalSkillStore
|
||||||
from storage.skill_catalog import SkillCatalogStore
|
from storage.skill_catalog import SkillCatalogStore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
class SkillSyncRunner:
|
||||||
"""Drives periodic sync between the Subscription Platform and local catalog.
|
"""Drives periodic sync between the Subscription Platform and local catalog.
|
||||||
|
|
||||||
@@ -160,11 +254,13 @@ class SkillSyncRunner:
|
|||||||
client: SubscriptionClient,
|
client: SubscriptionClient,
|
||||||
subscriptions: list[str],
|
subscriptions: list[str],
|
||||||
poll_interval: float = 300.0,
|
poll_interval: float = 300.0,
|
||||||
|
local_store: LocalSkillStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.client = client
|
self.client = client
|
||||||
self.subscriptions = list(subscriptions)
|
self.subscriptions = list(subscriptions)
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
|
self.local_store = local_store
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._tick_lock = threading.Lock()
|
self._tick_lock = threading.Lock()
|
||||||
@@ -209,12 +305,13 @@ class SkillSyncRunner:
|
|||||||
self._stop.wait(self.poll_interval)
|
self._stop.wait(self.poll_interval)
|
||||||
|
|
||||||
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||||
|
since_version = self.store._get_subscription_version(subscription_id)
|
||||||
try:
|
try:
|
||||||
delta = self.client.fetch_entitled_skills(subscription_id)
|
delta = self.client.fetch_entitled_skills(
|
||||||
except Exception as exc:
|
subscription_id, since_version=since_version
|
||||||
log.warning(
|
|
||||||
"skill sync fetch failed for %s: %s", subscription_id, exc
|
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
|
||||||
self.store._set_subscription_state(
|
self.store._set_subscription_state(
|
||||||
subscription_id,
|
subscription_id,
|
||||||
last_error=f"{type(exc).__name__}: {exc}",
|
last_error=f"{type(exc).__name__}: {exc}",
|
||||||
@@ -233,6 +330,11 @@ class SkillSyncRunner:
|
|||||||
for skill in delta.skills:
|
for skill in delta.skills:
|
||||||
self.store._apply_sync_upsert(skill, subscription_id)
|
self.store._apply_sync_upsert(skill, subscription_id)
|
||||||
for skill_id in delta.removed_ids:
|
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._apply_sync_remove(skill_id)
|
||||||
self.store._set_subscription_state(
|
self.store._set_subscription_state(
|
||||||
subscription_id,
|
subscription_id,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ from starlette.types import Scope
|
|||||||
|
|
||||||
from cloud.auth import (
|
from cloud.auth import (
|
||||||
ChainedAuthProvider,
|
ChainedAuthProvider,
|
||||||
ConfiguredEnrollmentTokenProvider,
|
|
||||||
RepositoryHostAuthProvider,
|
RepositoryHostAuthProvider,
|
||||||
|
UserSessionAuthProvider,
|
||||||
create_auth_provider,
|
create_auth_provider,
|
||||||
)
|
)
|
||||||
from cloud.config import CloudConfig
|
from cloud.config import CloudConfig
|
||||||
@@ -30,6 +31,8 @@ from cloud.control_config import (
|
|||||||
)
|
)
|
||||||
from cloud.database import CloudDatabase
|
from cloud.database import CloudDatabase
|
||||||
from cloud.internal_api.api import create_internal_router
|
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.plugins import PluginRegistry
|
||||||
from cloud.observability import (
|
from cloud.observability import (
|
||||||
CORRELATION_HEADER,
|
CORRELATION_HEADER,
|
||||||
@@ -42,6 +45,11 @@ from cloud.pool import DevicePool
|
|||||||
from cloud.scheduler import TaskScheduler
|
from cloud.scheduler import TaskScheduler
|
||||||
from cloud.schema import require_current_schema
|
from cloud.schema import require_current_schema
|
||||||
from cloud.sdk.api import create_cloud_router
|
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
|
from core.models import utc_now
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +80,8 @@ class CloudApplicationServices:
|
|||||||
scheduler: TaskScheduler
|
scheduler: TaskScheduler
|
||||||
plugin_registry: PluginRegistry
|
plugin_registry: PluginRegistry
|
||||||
auth_provider: Any
|
auth_provider: Any
|
||||||
|
user_auth_service: UserAuthService
|
||||||
|
llm_provider_service: LlmProviderService
|
||||||
|
|
||||||
|
|
||||||
class SpaStaticFiles(StaticFiles):
|
class SpaStaticFiles(StaticFiles):
|
||||||
@@ -103,19 +113,33 @@ def create_app(
|
|||||||
validate_control_config(control_config)
|
validate_control_config(control_config)
|
||||||
build_database = database_factory or _default_database_factory
|
build_database = database_factory or _default_database_factory
|
||||||
configured_auth_provider = create_auth_provider(
|
configured_auth_provider = create_auth_provider(
|
||||||
control_config.credentials,
|
|
||||||
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
|
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
|
||||||
)
|
)
|
||||||
repository = _RepositoryProxy()
|
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(
|
auth_provider = ChainedAuthProvider(
|
||||||
(
|
(
|
||||||
configured_auth_provider,
|
configured_auth_provider,
|
||||||
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
|
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
|
||||||
|
UserSessionAuthProvider(user_auth_service),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
enrollment_auth_provider = ConfiguredEnrollmentTokenProvider(
|
|
||||||
control_config.enrollment_credentials
|
|
||||||
)
|
|
||||||
domain_config = CloudConfig(
|
domain_config = CloudConfig(
|
||||||
lease_duration_seconds=control_config.lease_duration_seconds,
|
lease_duration_seconds=control_config.lease_duration_seconds,
|
||||||
)
|
)
|
||||||
@@ -128,6 +152,8 @@ def create_app(
|
|||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
plugin_registry=plugin_registry,
|
plugin_registry=plugin_registry,
|
||||||
auth_provider=auth_provider,
|
auth_provider=auth_provider,
|
||||||
|
user_auth_service=user_auth_service,
|
||||||
|
llm_provider_service=llm_provider_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -164,6 +190,19 @@ def create_app(
|
|||||||
),
|
),
|
||||||
name="cloud-lease-reaper",
|
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.worker_tasks = tuple(worker_tasks)
|
||||||
app.state.startup_complete = True
|
app.state.startup_complete = True
|
||||||
@@ -201,6 +240,12 @@ def create_app(
|
|||||||
correlation_token = bind_correlation_id(correlation_id)
|
correlation_token = bind_correlation_id(correlation_id)
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
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(
|
logger.info(
|
||||||
"cloud request completed",
|
"cloud request completed",
|
||||||
extra={
|
extra={
|
||||||
@@ -251,14 +296,69 @@ def create_app(
|
|||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
plugin_registry=plugin_registry,
|
plugin_registry=plugin_registry,
|
||||||
auth_provider=auth_provider,
|
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(
|
app.include_router(
|
||||||
create_internal_router(
|
create_internal_router(
|
||||||
pool=pool,
|
pool=pool,
|
||||||
auth_provider=auth_provider,
|
auth_provider=auth_provider,
|
||||||
enrollment_auth_provider=enrollment_auth_provider,
|
|
||||||
lease_duration_seconds=control_config.lease_duration_seconds,
|
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(
|
async def _run_scheduler_loop(
|
||||||
services: CloudApplicationServices,
|
services: CloudApplicationServices,
|
||||||
stop: asyncio.Event,
|
stop: asyncio.Event,
|
||||||
@@ -324,6 +455,10 @@ async def _run_lease_reaper_loop(
|
|||||||
now=utc_now(),
|
now=utc_now(),
|
||||||
max_attempts=max_attempts,
|
max_attempts=max_attempts,
|
||||||
)
|
)
|
||||||
|
services.repository.cleanup_expired_token_reservations(
|
||||||
|
now=utc_now(),
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"cloud lifecycle iteration failed",
|
"cloud lifecycle iteration failed",
|
||||||
@@ -341,3 +476,28 @@ async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool:
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
return False
|
return False
|
||||||
return True
|
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,7 +2,7 @@
|
|||||||
name = "device-cloud-api"
|
name = "device-cloud-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Deployable Cloud Control Plane API for Device Agent Runtime."
|
description = "Deployable Cloud Control Plane API for Device Agent Runtime."
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.13,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"device-cloud-platform==0.1.0",
|
"device-cloud-platform==0.1.0",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
device-cloud-api = "cloud_api.cli:main"
|
device-cloud-api = "cloud_api.cli:main"
|
||||||
|
device-cloud-admin = "cloud_api.admin_cli:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=69"]
|
requires = ["setuptools>=69"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
import cloud_api.app as app_module
|
import cloud_api.app as app_module
|
||||||
from cloud_api.app import create_app
|
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.control_config import CloudConfigurationError, CloudControlConfig
|
||||||
from cloud.database import CloudDatabase
|
from cloud.database import CloudDatabase
|
||||||
from cloud.pool import PooledDevice
|
from cloud.pool import PooledDevice
|
||||||
@@ -33,24 +33,8 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}"
|
database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}"
|
||||||
enrollment_token = "one-time-enrollment-token"
|
|
||||||
host_token = "host-token-" + ("x" * 40)
|
host_token = "host-token-" + ("x" * 40)
|
||||||
config = CloudControlConfig(
|
config = CloudControlConfig(database_url=database_url)
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
enrollment_payload = {
|
enrollment_payload = {
|
||||||
"agent_instance_id": "agent-instance-a",
|
"agent_instance_id": "agent-instance-a",
|
||||||
"host_token": host_token,
|
"host_token": host_token,
|
||||||
@@ -61,7 +45,6 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
enrolled = client.post(
|
enrolled = client.post(
|
||||||
"/internal/v1/enrollments",
|
"/internal/v1/enrollments",
|
||||||
headers={"Authorization": f"Bearer {enrollment_token}"},
|
|
||||||
json=enrollment_payload,
|
json=enrollment_payload,
|
||||||
)
|
)
|
||||||
assert enrolled.status_code == 201
|
assert enrolled.status_code == 201
|
||||||
@@ -70,21 +53,21 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
|
|||||||
|
|
||||||
retried = client.post(
|
retried = client.post(
|
||||||
"/internal/v1/enrollments",
|
"/internal/v1/enrollments",
|
||||||
headers={"Authorization": f"Bearer {enrollment_token}"},
|
|
||||||
json=enrollment_payload,
|
json=enrollment_payload,
|
||||||
)
|
)
|
||||||
assert retried.status_code == 201
|
assert retried.status_code == 201
|
||||||
assert retried.json()["host_id"] == host_id
|
assert retried.json()["host_id"] == host_id
|
||||||
|
|
||||||
reused = client.post(
|
another_host = client.post(
|
||||||
"/internal/v1/enrollments",
|
"/internal/v1/enrollments",
|
||||||
headers={"Authorization": f"Bearer {enrollment_token}"},
|
|
||||||
json={
|
json={
|
||||||
**enrollment_payload,
|
**enrollment_payload,
|
||||||
"agent_instance_id": "agent-instance-b",
|
"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(
|
device = client.post(
|
||||||
f"/internal/v1/hosts/{host_id}/devices/enroll",
|
f"/internal/v1/hosts/{host_id}/devices/enroll",
|
||||||
@@ -601,14 +584,16 @@ def _wait_until(predicate, timeout_seconds: float = 1.0) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def test_production_app_rejects_missing_credentials() -> None:
|
def test_production_app_allows_no_static_credentials() -> None:
|
||||||
with pytest.raises(CloudConfigurationError, match="credential"):
|
app = create_app(
|
||||||
create_app(
|
config=CloudControlConfig(
|
||||||
config=CloudControlConfig(
|
environment="production",
|
||||||
environment="production",
|
database_url="postgresql://db/cloud",
|
||||||
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:
|
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
|
||||||
@@ -1,22 +1,46 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from driver.registry import build_driver_factory
|
|
||||||
from host_agent.assignment import AssignmentExecutor
|
from host_agent.assignment import AssignmentExecutor
|
||||||
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
||||||
from host_agent.config import HostAgentConfig, load_host_agent_config
|
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.enrollment import resolve_host_identity
|
||||||
from host_agent.execution import create_execution_factories
|
from host_agent.execution import create_execution_factories
|
||||||
from host_agent.heartbeat import HeartbeatSynchronizer
|
from host_agent.heartbeat import HeartbeatSynchronizer
|
||||||
|
from host_agent.history import ConsoleHistoryStore
|
||||||
from host_agent.identity import HostIdentityStore
|
from host_agent.identity import HostIdentityStore
|
||||||
|
from host_agent.instance_lock import InstanceLock
|
||||||
from host_agent.lease import ActiveAssignmentRunner
|
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.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.device_config import DeviceConfigStore
|
||||||
|
from storage.task_metadata import TaskMetadataStore
|
||||||
|
from storage.timeline import Timeline
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -24,14 +48,36 @@ class HostAgentApplication:
|
|||||||
client: HostAgentClient
|
client: HostAgentClient
|
||||||
heartbeat: HeartbeatSynchronizer
|
heartbeat: HeartbeatSynchronizer
|
||||||
processor: AssignmentProcessor
|
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:
|
def run(self) -> None:
|
||||||
asyncio.run(self.run_async())
|
asyncio.run(self.run_async())
|
||||||
|
|
||||||
async def run_async(self, stop: asyncio.Event | None = None) -> None:
|
async def run_async(self, stop: asyncio.Event | None = None) -> None:
|
||||||
stop_requested = stop or asyncio.Event()
|
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_stop = asyncio.Event()
|
||||||
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
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
|
active_processing: asyncio.Task[AssignmentProcessingResult] | None = None
|
||||||
try:
|
try:
|
||||||
while not stop_requested.is_set():
|
while not stop_requested.is_set():
|
||||||
@@ -60,12 +106,28 @@ class HostAgentApplication:
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await asyncio.shield(active_processing)
|
await asyncio.shield(active_processing)
|
||||||
heartbeat_stop.set()
|
heartbeat_stop.set()
|
||||||
|
supervisor_stop.set()
|
||||||
|
if self.console_server is not None:
|
||||||
|
self.console_server.should_exit = True
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self.heartbeat.sync_once()
|
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:
|
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()
|
await self.client.aclose()
|
||||||
|
if self.instance_lock is not None:
|
||||||
|
self.instance_lock.release()
|
||||||
|
|
||||||
async def _claim_until_stopped(
|
async def _claim_until_stopped(
|
||||||
self,
|
self,
|
||||||
@@ -94,6 +156,11 @@ class HostAgentApplication:
|
|||||||
return await claim
|
return await claim
|
||||||
|
|
||||||
|
|
||||||
|
class _EmbeddedConsoleServer(uvicorn.Server):
|
||||||
|
def install_signal_handlers(self) -> None:
|
||||||
|
"""Host Agent owns process-level signal handling."""
|
||||||
|
|
||||||
|
|
||||||
def create_application(
|
def create_application(
|
||||||
*,
|
*,
|
||||||
config: HostAgentConfig | None = None,
|
config: HostAgentConfig | None = None,
|
||||||
@@ -103,62 +170,231 @@ def create_application(
|
|||||||
enrollment_client: HostAgentEnrollmentClient | None = None,
|
enrollment_client: HostAgentEnrollmentClient | None = None,
|
||||||
) -> HostAgentApplication:
|
) -> HostAgentApplication:
|
||||||
startup_config = config or load_host_agent_config()
|
startup_config = config or load_host_agent_config()
|
||||||
config_store = device_config_store or DeviceConfigStore()
|
instance_lock = InstanceLock(startup_config.identity_path.parent)
|
||||||
owned_enrollment_client = enrollment_client is None
|
instance_lock.acquire()
|
||||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
|
||||||
try:
|
try:
|
||||||
resolved_config = resolve_host_identity(
|
config_store = device_config_store or DeviceConfigStore()
|
||||||
startup_config,
|
resolved_identity_store = identity_store or HostIdentityStore(
|
||||||
identity_store=identity_store
|
startup_config.identity_path
|
||||||
or HostIdentityStore(startup_config.identity_path),
|
|
||||||
client=bootstrap_client,
|
|
||||||
)
|
)
|
||||||
bootstrap_client.config = resolved_config
|
owned_enrollment_client = enrollment_client is None
|
||||||
resolved_manager = manager or _configured_device_manager(
|
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
||||||
config_store,
|
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,
|
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:
|
console_server = _EmbeddedConsoleServer(
|
||||||
if owned_enrollment_client:
|
uvicorn.Config(
|
||||||
bootstrap_client.close()
|
console_app,
|
||||||
client = HostAgentClient(resolved_config)
|
host=resolved_config.console_bind_host,
|
||||||
heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config)
|
port=resolved_config.console_port,
|
||||||
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
|
log_level="warning",
|
||||||
active_runner = ActiveAssignmentRunner(client, executor)
|
)
|
||||||
return HostAgentApplication(
|
)
|
||||||
client=client,
|
|
||||||
heartbeat=heartbeat,
|
heartbeat = HeartbeatSynchronizer(
|
||||||
processor=AssignmentProcessor(client, active_runner),
|
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(
|
def _configured_device_manager(
|
||||||
config_store: DeviceConfigStore,
|
config_store: DeviceConfigStore,
|
||||||
*,
|
*,
|
||||||
config: HostAgentConfig,
|
config: HostAgentConfig,
|
||||||
enrollment_client: HostAgentEnrollmentClient,
|
enrollment_client: HostAgentEnrollmentClient | None,
|
||||||
) -> DeviceManager:
|
) -> DeviceManager:
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
for device in config_store.list():
|
for device in config_store.list():
|
||||||
runtime_device_id = device["device_id"]
|
register_local_device(
|
||||||
if config.enrollment_managed:
|
config_store,
|
||||||
enrollment = enrollment_client.enroll_device(
|
manager,
|
||||||
local_device_id=device["device_id"],
|
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"],
|
|
||||||
driver_type=device["driver_type"],
|
driver_type=device["driver_type"],
|
||||||
connection_info=device["connection_info"],
|
connection_info=device["connection_info"],
|
||||||
|
name=device["name"],
|
||||||
|
config=config,
|
||||||
|
enrollment_client=enrollment_client,
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from core.models import Task
|
from core.models import Task
|
||||||
from host_agent.execution import ExecutionFactories
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -17,43 +23,81 @@ class AssignmentExecutionResult:
|
|||||||
|
|
||||||
|
|
||||||
class AssignmentExecutor:
|
class AssignmentExecutor:
|
||||||
def __init__(self, factories: ExecutionFactories) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
factories: ExecutionFactories,
|
||||||
|
*,
|
||||||
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||||
|
) -> None:
|
||||||
self.factories = factories
|
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(
|
def execute(
|
||||||
self,
|
self,
|
||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None = None,
|
should_stop: Callable[[], bool] | None = None,
|
||||||
|
stop_reason: Callable[[], str | None] | None = None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> 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(
|
return AssignmentExecutionResult(
|
||||||
status="failed",
|
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(
|
def _execute_goal(
|
||||||
self,
|
self,
|
||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None,
|
should_stop: Callable[[], bool] | None,
|
||||||
|
stop_reason: Callable[[], str | None] | None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
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 = self.factories.task_runner_factory()
|
||||||
|
runner.on_step_progress = self._progress.update
|
||||||
if should_stop is None:
|
if should_stop is None:
|
||||||
completed = runner.run(task)
|
completed = runner.run(task)
|
||||||
else:
|
else:
|
||||||
completed = runner.run(task, should_stop=should_stop)
|
completed = runner.run(
|
||||||
|
task, should_stop=should_stop, stop_reason=stop_reason
|
||||||
|
)
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="done" if completed.status == "completed" else "failed",
|
status=_terminal_status(completed.status),
|
||||||
failure_reason=completed.failure_reason,
|
failure_reason=completed.failure_reason,
|
||||||
metadata={
|
metadata={
|
||||||
"runtime_task_id": completed.id,
|
"runtime_task_id": completed.id,
|
||||||
@@ -66,6 +110,7 @@ class AssignmentExecutor:
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None,
|
should_stop: Callable[[], bool] | None,
|
||||||
|
stop_reason: Callable[[], str | None] | None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
definition_id = assignment.workflow_definition_id or ""
|
definition_id = assignment.workflow_definition_id or ""
|
||||||
definition = self.factories.workflow_store.get_definition(definition_id)
|
definition = self.factories.workflow_store.get_definition(definition_id)
|
||||||
@@ -82,9 +127,10 @@ class AssignmentExecutor:
|
|||||||
definition,
|
definition,
|
||||||
device_id=assignment.device_id,
|
device_id=assignment.device_id,
|
||||||
should_stop=should_stop,
|
should_stop=should_stop,
|
||||||
|
stop_reason=stop_reason,
|
||||||
)
|
)
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="done" if run.status == "completed" else "failed",
|
status=_terminal_status(run.status),
|
||||||
failure_reason=(
|
failure_reason=(
|
||||||
None if run.status == "completed" else f"workflow ended as {run.status}"
|
None if run.status == "completed" else f"workflow ended as {run.status}"
|
||||||
),
|
),
|
||||||
@@ -93,3 +139,11 @@ class AssignmentExecutor:
|
|||||||
"workflow_status": run.status,
|
"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"
|
||||||
|
|||||||
@@ -1,12 +1,117 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from host_agent.app import create_application
|
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:
|
def main(argv: Sequence[str] | None = None) -> None:
|
||||||
|
_load_dotenv()
|
||||||
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
||||||
parser.parse_args(argv)
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
create_application().run()
|
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
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ from cloud.internal_api.models import (
|
|||||||
DeviceSnapshotModel,
|
DeviceSnapshotModel,
|
||||||
HeartbeatResponse,
|
HeartbeatResponse,
|
||||||
HostEnrollmentResponse,
|
HostEnrollmentResponse,
|
||||||
|
HostTaskCancellationResponse,
|
||||||
|
HostTaskSubmissionResponse,
|
||||||
LeaseRenewalResponse,
|
LeaseRenewalResponse,
|
||||||
|
TaskProgressModel,
|
||||||
TerminalResultResponse,
|
TerminalResultResponse,
|
||||||
)
|
)
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
|
from host_agent.progress import TaskProgressSnapshot
|
||||||
|
|
||||||
|
_VALID_STEP_STATUSES = frozenset({"running", "completed", "failed"})
|
||||||
|
|
||||||
|
|
||||||
class HostAgentAPIError(RuntimeError):
|
class HostAgentAPIError(RuntimeError):
|
||||||
@@ -33,6 +39,21 @@ class StaleLeaseError(HostAgentAPIError):
|
|||||||
pass
|
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:
|
class HostAgentEnrollmentClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -53,12 +74,10 @@ class HostAgentEnrollmentClient:
|
|||||||
host_token: str,
|
host_token: str,
|
||||||
display_name: str | None,
|
display_name: str | None,
|
||||||
) -> HostEnrollmentResponse:
|
) -> HostEnrollmentResponse:
|
||||||
if not self.config.enrollment_token:
|
|
||||||
raise HostAgentAPIError(0, "Host enrollment token is unavailable")
|
|
||||||
response = self._request(
|
response = self._request(
|
||||||
"POST",
|
"POST",
|
||||||
"/internal/v1/enrollments",
|
"/internal/v1/enrollments",
|
||||||
token=self.config.enrollment_token,
|
token=None,
|
||||||
json={
|
json={
|
||||||
"agent_instance_id": agent_instance_id,
|
"agent_instance_id": agent_instance_id,
|
||||||
"host_token": host_token,
|
"host_token": host_token,
|
||||||
@@ -99,9 +118,10 @@ class HostAgentEnrollmentClient:
|
|||||||
method: str,
|
method: str,
|
||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
token: str,
|
token: str | None,
|
||||||
json: dict[str, Any],
|
json: dict[str, Any],
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||||
backoff = self.config.retry_backoff_seconds
|
backoff = self.config.retry_backoff_seconds
|
||||||
for attempt in range(1, self.config.max_retry_attempts + 1):
|
for attempt in range(1, self.config.max_retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
@@ -109,7 +129,7 @@ class HostAgentEnrollmentClient:
|
|||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
json=json,
|
json=json,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers=headers,
|
||||||
)
|
)
|
||||||
except httpx.TransportError:
|
except httpx.TransportError:
|
||||||
if attempt == self.config.max_retry_attempts:
|
if attempt == self.config.max_retry_attempts:
|
||||||
@@ -147,18 +167,74 @@ class HostAgentClient:
|
|||||||
devices: list[DeviceSnapshotModel],
|
devices: list[DeviceSnapshotModel],
|
||||||
*,
|
*,
|
||||||
address: str | None = None,
|
address: str | None = None,
|
||||||
|
policy_revision: int = 0,
|
||||||
|
mcp_busy_device_ids: list[str] | None = None,
|
||||||
) -> HeartbeatResponse:
|
) -> 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(
|
response = await self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
|
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
|
||||||
json={
|
json=payload,
|
||||||
"host_id": self.config.host_id,
|
|
||||||
"address": address,
|
|
||||||
"devices": [device.model_dump(mode="json") for device in devices],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return HeartbeatResponse.model_validate(response.json())
|
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:
|
async def claim(self) -> AssignmentModel | None:
|
||||||
response = await self._request(
|
response = await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -174,19 +250,33 @@ class HostAgentClient:
|
|||||||
async def renew(
|
async def renew(
|
||||||
self,
|
self,
|
||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
|
*,
|
||||||
|
progress: TaskProgressSnapshot | None = None,
|
||||||
) -> LeaseRenewalResponse:
|
) -> 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(
|
response = await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
(
|
(
|
||||||
f"/internal/v1/hosts/{self.config.host_id}/assignments/"
|
f"/internal/v1/hosts/{self.config.host_id}/assignments/"
|
||||||
f"{assignment.task_id}/renew"
|
f"{assignment.task_id}/renew"
|
||||||
),
|
),
|
||||||
json={
|
json=payload,
|
||||||
"host_id": self.config.host_id,
|
|
||||||
"task_id": assignment.task_id,
|
|
||||||
"attempt": assignment.attempt,
|
|
||||||
"lease_id": assignment.lease_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return LeaseRenewalResponse.model_validate(response.json())
|
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
|
||||||
@@ -11,13 +11,24 @@ class HostAgentConfigurationError(ValueError):
|
|||||||
"""Raised when Host Agent process configuration is invalid."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class HostAgentConfig:
|
class HostAgentConfig:
|
||||||
control_plane_url: str
|
control_plane_url: str = ""
|
||||||
|
mode: str = "cloud"
|
||||||
host_id: str = ""
|
host_id: str = ""
|
||||||
token: str = field(default="", repr=False)
|
token: str = field(default="", repr=False)
|
||||||
enrollment_token: str = field(default="", repr=False)
|
|
||||||
identity_path: Path = Path("tasks/host_identity.json")
|
identity_path: Path = Path("tasks/host_identity.json")
|
||||||
|
local_account_path: Path = Path("tasks/host_local_account.json")
|
||||||
enrollment_managed: bool = False
|
enrollment_managed: bool = False
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
heartbeat_interval_seconds: float = 30.0
|
heartbeat_interval_seconds: float = 30.0
|
||||||
@@ -25,49 +36,62 @@ class HostAgentConfig:
|
|||||||
retry_backoff_seconds: float = 1.0
|
retry_backoff_seconds: float = 1.0
|
||||||
max_retry_backoff_seconds: float = 30.0
|
max_retry_backoff_seconds: float = 30.0
|
||||||
max_retry_attempts: int = 5
|
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(
|
def load_host_agent_config(
|
||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
) -> HostAgentConfig:
|
) -> HostAgentConfig:
|
||||||
values = os.environ if env is None else env
|
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 = (
|
control_plane_url = (
|
||||||
values.get(
|
values.get(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL",
|
"HOST_AGENT_CONTROL_PLANE_URL",
|
||||||
"http://127.0.0.1:8001",
|
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
|
||||||
)
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.rstrip("/")
|
.rstrip("/")
|
||||||
)
|
)
|
||||||
parsed_url = urlparse(control_plane_url)
|
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(
|
raise HostAgentConfigurationError(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
"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(
|
identity_path = Path(
|
||||||
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip()
|
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():
|
local_account_path = Path(
|
||||||
raise HostAgentConfigurationError(
|
values.get(
|
||||||
"explicit Host credentials, an enrollment token, or existing identity state "
|
"HOST_AGENT_LOCAL_ACCOUNT_PATH", "tasks/host_local_account.json"
|
||||||
"is required"
|
).strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
config = HostAgentConfig(
|
config = HostAgentConfig(
|
||||||
control_plane_url=control_plane_url,
|
control_plane_url=control_plane_url,
|
||||||
host_id=host_id,
|
mode=mode,
|
||||||
token=token,
|
|
||||||
enrollment_token=enrollment_token,
|
|
||||||
identity_path=identity_path,
|
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,
|
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
||||||
heartbeat_interval_seconds=_positive_float(
|
heartbeat_interval_seconds=_positive_float(
|
||||||
values,
|
values,
|
||||||
@@ -94,14 +118,101 @@ def load_host_agent_config(
|
|||||||
"HOST_AGENT_MAX_RETRY_ATTEMPTS",
|
"HOST_AGENT_MAX_RETRY_ATTEMPTS",
|
||||||
5,
|
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:
|
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
|
||||||
raise HostAgentConfigurationError(
|
raise HostAgentConfigurationError(
|
||||||
"maximum retry backoff must not be less than initial backoff"
|
"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
|
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(
|
def _positive_float(
|
||||||
values: Mapping[str, str],
|
values: Mapping[str, str],
|
||||||
name: str,
|
name: str,
|
||||||
@@ -134,3 +245,14 @@ def _positive_int(
|
|||||||
if value <= 0:
|
if value <= 0:
|
||||||
raise HostAgentConfigurationError(f"{name} must be greater than zero")
|
raise HostAgentConfigurationError(f"{name} must be greater than zero")
|
||||||
return value
|
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 dataclasses import replace
|
||||||
|
|
||||||
from host_agent.client import HostAgentEnrollmentClient
|
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
|
from host_agent.identity import HostIdentityStore
|
||||||
|
|
||||||
|
|
||||||
@@ -17,10 +17,6 @@ def resolve_host_identity(
|
|||||||
return config
|
return config
|
||||||
state = identity_store.load_or_create()
|
state = identity_store.load_or_create()
|
||||||
if state.host_id is None:
|
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(
|
response = client.enroll_host(
|
||||||
agent_instance_id=state.agent_instance_id,
|
agent_instance_id=state.agent_instance_id,
|
||||||
host_token=state.token,
|
host_token=state.token,
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from device.manager import DeviceManager
|
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.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 runtime.task import TaskRunner
|
||||||
from storage.task_metadata import TaskMetadataStore
|
from storage.task_metadata import TaskMetadataStore
|
||||||
from storage.timeline import Timeline
|
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.runner import WorkflowRunner
|
||||||
from workflow.store import WorkflowStore
|
from workflow.store import WorkflowStore
|
||||||
|
|
||||||
@@ -17,6 +26,7 @@ class ExecutionFactories:
|
|||||||
task_runner_factory: Callable[[], TaskRunner]
|
task_runner_factory: Callable[[], TaskRunner]
|
||||||
workflow_runner_factory: Callable[[], WorkflowRunner]
|
workflow_runner_factory: Callable[[], WorkflowRunner]
|
||||||
workflow_store: WorkflowStore
|
workflow_store: WorkflowStore
|
||||||
|
metadata_store: TaskMetadataStore | None = None
|
||||||
|
|
||||||
|
|
||||||
def create_execution_factories(
|
def create_execution_factories(
|
||||||
@@ -25,14 +35,29 @@ def create_execution_factories(
|
|||||||
workflow_store: WorkflowStore | None = None,
|
workflow_store: WorkflowStore | None = None,
|
||||||
metadata_store: TaskMetadataStore | None = None,
|
metadata_store: TaskMetadataStore | None = None,
|
||||||
timeline: Timeline | None = None,
|
timeline: Timeline | None = None,
|
||||||
|
host_agent_config: HostAgentConfig | None = None,
|
||||||
|
conversation_log: Any | None = None,
|
||||||
) -> ExecutionFactories:
|
) -> ExecutionFactories:
|
||||||
shared_workflow_store = workflow_store or WorkflowStore()
|
shared_workflow_store = workflow_store or WorkflowStore()
|
||||||
|
resolved_host_agent_config = host_agent_config
|
||||||
|
|
||||||
def create_task_runner() -> TaskRunner:
|
def create_task_runner() -> TaskRunner:
|
||||||
return TaskRunner(
|
return TaskRunner(
|
||||||
executor=Executor(tools=default_tool_registry(manager=manager)),
|
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,
|
metadata_store=metadata_store,
|
||||||
timeline=timeline,
|
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:
|
def create_workflow_runner() -> WorkflowRunner:
|
||||||
@@ -45,4 +70,59 @@ def create_execution_factories(
|
|||||||
task_runner_factory=create_task_runner,
|
task_runner_factory=create_task_runner,
|
||||||
workflow_runner_factory=create_workflow_runner,
|
workflow_runner_factory=create_workflow_runner,
|
||||||
workflow_store=shared_workflow_store,
|
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
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ from core.errors import DeviceRuntimeError
|
|||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.client import HostAgentClient
|
from host_agent.client import HostAgentClient
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
|
from host_agent.policy_cache import HostPolicyCacheStore
|
||||||
|
from host_agent.status import AgentStatusTracker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from host_agent.mcp_lock import McpBusyTracker
|
||||||
|
|
||||||
|
|
||||||
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
||||||
return [
|
return [
|
||||||
@@ -34,18 +38,63 @@ class HeartbeatSynchronizer:
|
|||||||
*,
|
*,
|
||||||
address: str | None = None,
|
address: str | None = None,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
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:
|
) -> None:
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.client = client
|
self.client = client
|
||||||
self.config = config
|
self.config = config
|
||||||
self.address = address
|
self.address = address
|
||||||
self._sleep = sleep
|
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:
|
async def sync_once(self) -> HeartbeatResponse:
|
||||||
return await self.client.heartbeat(
|
self.probe_connected_devices()
|
||||||
build_device_snapshot(self.manager),
|
self.connect_devices()
|
||||||
address=self.address,
|
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:
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
self.connect_devices()
|
self.connect_devices()
|
||||||
@@ -61,9 +110,19 @@ class HeartbeatSynchronizer:
|
|||||||
|
|
||||||
def connect_devices(self) -> None:
|
def connect_devices(self) -> None:
|
||||||
for device in self.manager.list_devices():
|
for device in self.manager.list_devices():
|
||||||
if device.status != "idle":
|
if device.status not in {"idle", "offline", "error"}:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
self.manager.connect(device.id)
|
self.manager.connect(device.id)
|
||||||
except DeviceRuntimeError:
|
except DeviceRuntimeError:
|
||||||
continue
|
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"]))
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ import httpx
|
|||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from host_agent.assignment import AssignmentExecutionResult
|
from host_agent.assignment import AssignmentExecutionResult
|
||||||
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
||||||
|
from host_agent.progress import TaskProgressSnapshot
|
||||||
|
from runtime.task import is_cancellation_reason
|
||||||
|
|
||||||
|
|
||||||
class InterruptibleAssignmentExecutor(Protocol):
|
class InterruptibleAssignmentExecutor(Protocol):
|
||||||
@@ -19,8 +21,11 @@ class InterruptibleAssignmentExecutor(Protocol):
|
|||||||
assignment: AssignmentModel,
|
assignment: AssignmentModel,
|
||||||
*,
|
*,
|
||||||
should_stop: Callable[[], bool] | None = None,
|
should_stop: Callable[[], bool] | None = None,
|
||||||
|
stop_reason: Callable[[], str | None] | None = None,
|
||||||
) -> AssignmentExecutionResult: ...
|
) -> AssignmentExecutionResult: ...
|
||||||
|
|
||||||
|
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
||||||
|
|
||||||
|
|
||||||
class LeaseGuard:
|
class LeaseGuard:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -33,6 +38,10 @@ class LeaseGuard:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._reason
|
return self._reason
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellation(self) -> bool:
|
||||||
|
return is_cancellation_reason(self.reason)
|
||||||
|
|
||||||
def is_lost(self) -> bool:
|
def is_lost(self) -> bool:
|
||||||
return self._lost.is_set()
|
return self._lost.is_set()
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ class ActiveAssignmentRunner:
|
|||||||
self.executor.execute,
|
self.executor.execute,
|
||||||
assignment,
|
assignment,
|
||||||
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
|
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
|
||||||
|
stop_reason=lambda: guard.reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
renewal = asyncio.create_task(
|
renewal = asyncio.create_task(
|
||||||
@@ -92,7 +102,9 @@ class ActiveAssignmentRunner:
|
|||||||
if done:
|
if done:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
response = await self.client.renew(assignment)
|
response = await self.client.renew(
|
||||||
|
assignment, progress=self.executor.latest_progress()
|
||||||
|
)
|
||||||
except StaleLeaseError:
|
except StaleLeaseError:
|
||||||
guard.mark_lost("lease rejected by control plane")
|
guard.mark_lost("lease rejected by control plane")
|
||||||
return
|
return
|
||||||
@@ -103,4 +115,7 @@ class ActiveAssignmentRunner:
|
|||||||
guard.mark_lost("lease renewal failed after transport retries")
|
guard.mark_lost("lease renewal failed after transport retries")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
if response.cancel_requested:
|
||||||
|
guard.mark_lost("cancellation requested by control plane")
|
||||||
|
return
|
||||||
lease_expires_at = response.lease_expires_at
|
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
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal, Protocol
|
from typing import TYPE_CHECKING, Literal, Protocol
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from host_agent.assignment import AssignmentExecutionResult
|
from host_agent.assignment import AssignmentExecutionResult
|
||||||
from host_agent.client import HostAgentClient
|
from host_agent.client import HostAgentClient
|
||||||
|
from host_agent.status import AgentStatusTracker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
class ActiveAssignmentExecutor(Protocol):
|
class ActiveAssignmentExecutor(Protocol):
|
||||||
@@ -25,24 +29,45 @@ class AssignmentProcessor:
|
|||||||
self,
|
self,
|
||||||
client: HostAgentClient,
|
client: HostAgentClient,
|
||||||
active_executor: ActiveAssignmentExecutor,
|
active_executor: ActiveAssignmentExecutor,
|
||||||
|
*,
|
||||||
|
status_tracker: AgentStatusTracker | None = None,
|
||||||
|
on_result: Callable[[AssignmentModel, AssignmentProcessingResult], None]
|
||||||
|
| None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.active_executor = active_executor
|
self.active_executor = active_executor
|
||||||
|
self.status_tracker = status_tracker
|
||||||
|
self.on_result = on_result
|
||||||
|
|
||||||
def request_stop(self) -> None:
|
def request_stop(self) -> None:
|
||||||
self.active_executor.request_stop()
|
self.active_executor.request_stop()
|
||||||
|
|
||||||
async def process(self, assignment: AssignmentModel) -> AssignmentProcessingResult:
|
async def process(self, assignment: AssignmentModel) -> AssignmentProcessingResult:
|
||||||
execution = await self.active_executor.run(assignment)
|
if self.status_tracker is not None:
|
||||||
status = "done" if execution.status == "done" else "failed"
|
self.status_tracker.mark_assignment_started(assignment)
|
||||||
failure_reason = execution.failure_reason if status == "failed" else None
|
try:
|
||||||
response = await self.client.report_result(
|
execution = await self.active_executor.run(assignment)
|
||||||
assignment,
|
if execution.status in {"done", "cancelled"}:
|
||||||
status=status,
|
status = execution.status
|
||||||
failure_reason=failure_reason,
|
else:
|
||||||
result=dict(execution.metadata),
|
status = "failed"
|
||||||
)
|
failure_reason = execution.failure_reason if status != "done" else None
|
||||||
return AssignmentProcessingResult(
|
response = await self.client.report_result(
|
||||||
execution=execution,
|
assignment,
|
||||||
report_status=response.status,
|
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()
|
||||||
@@ -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">← 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 %}
|
||||||
@@ -2,11 +2,16 @@
|
|||||||
name = "device-host-agent"
|
name = "device-host-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Outbound device-host worker for the Device Cloud Platform."
|
description = "Outbound device-host worker for the Device Cloud Platform."
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.13,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"device-agent-runtime==0.1.0",
|
"device-agent-runtime==0.1.0",
|
||||||
"device-cloud-platform==0.1.0",
|
"device-cloud-platform==0.1.0",
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"filelock>=3.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"mcp>=1.28,<2",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -20,6 +25,9 @@ build-backend = "setuptools.build_meta"
|
|||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["host_agent*"]
|
include = ["host_agent*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"host_agent.web" = ["templates/*.html"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
device-agent-runtime = { workspace = true }
|
device-agent-runtime = { workspace = true }
|
||||||
device-cloud-platform = { workspace = true }
|
device-cloud-platform = { workspace = true }
|
||||||
|
|||||||
@@ -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 = "<script>alert(1)</script>"
|
||||||
|
|
||||||
|
|
||||||
|
@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 "<script>alert(1)</script>" in result
|
||||||
|
assert "<script>alert(1)</script>" not in result
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import socket
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from cloud.internal_api.models import (
|
from cloud.internal_api.models import (
|
||||||
AssignmentModel,
|
AssignmentModel,
|
||||||
DeviceEnrollmentResponse,
|
DeviceEnrollmentResponse,
|
||||||
@@ -11,9 +16,29 @@ from cloud.internal_api.models import (
|
|||||||
)
|
)
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.app import HostAgentApplication, create_application
|
from host_agent.app import HostAgentApplication, create_application
|
||||||
|
from host_agent.assignment import AssignmentExecutor
|
||||||
from host_agent.config import HostAgentConfig
|
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.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.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:
|
def _config() -> HostAgentConfig:
|
||||||
@@ -88,7 +113,6 @@ def test_create_application_enrolls_host_and_devices_before_managed_startup(
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.config = HostAgentConfig(
|
self.config = HostAgentConfig(
|
||||||
control_plane_url="https://control.example",
|
control_plane_url="https://control.example",
|
||||||
enrollment_token="one-time-token",
|
|
||||||
enrollment_managed=True,
|
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"]
|
assert events == ["work-finished", "final-heartbeat", "closed"]
|
||||||
|
|
||||||
asyncio.run(scenario())
|
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:
|
def test_goal_assignment_executes_through_task_runner() -> None:
|
||||||
received: list[Task] = []
|
received: list[Task] = []
|
||||||
|
created: list[tuple[str, str | None, int | None]] = []
|
||||||
|
|
||||||
class FakeTaskRunner:
|
class FakeTaskRunner:
|
||||||
def run(self, task: Task) -> Task:
|
def run(self, task: Task) -> Task:
|
||||||
@@ -32,10 +33,21 @@ def test_goal_assignment_executes_through_task_runner() -> None:
|
|||||||
task.status = "completed"
|
task.status = "completed"
|
||||||
return task
|
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(
|
factories = ExecutionFactories(
|
||||||
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
|
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
|
||||||
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
|
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
|
||||||
workflow_store=object(), # type: ignore[arg-type]
|
workflow_store=object(), # type: ignore[arg-type]
|
||||||
|
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
result = AssignmentExecutor(factories).execute(_assignment())
|
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].goal == "open settings"
|
||||||
assert received[0].device_id == "device-a"
|
assert received[0].device_id == "device-a"
|
||||||
assert result.metadata["runtime_task_id"] == received[0].id
|
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:
|
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"
|
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:
|
def test_workflow_assignment_loads_and_executes_definition() -> None:
|
||||||
definition = object()
|
definition = object()
|
||||||
calls: list[tuple[object, str]] = []
|
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:
|
def test_unknown_workflow_fails_without_running() -> None:
|
||||||
class FakeWorkflowStore:
|
class FakeWorkflowStore:
|
||||||
def get_definition(self, definition_id: str):
|
def get_definition(self, definition_id: str):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +11,8 @@ from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
|
|||||||
from host_agent.client import (
|
from host_agent.client import (
|
||||||
HostAgentClient,
|
HostAgentClient,
|
||||||
HostAgentEnrollmentClient,
|
HostAgentEnrollmentClient,
|
||||||
|
HostAgentAPIError,
|
||||||
|
HostTaskSubmissionUnknownError,
|
||||||
StaleLeaseError,
|
StaleLeaseError,
|
||||||
)
|
)
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
@@ -138,6 +140,30 @@ def test_stale_lease_response_raises_typed_error_without_retry() -> None:
|
|||||||
assert attempts == 1
|
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:
|
def test_result_report_retries_identical_payload_after_response_loss() -> None:
|
||||||
payloads: list[dict[str, object]] = []
|
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"
|
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] = []
|
requests: list[httpx.Request] = []
|
||||||
host_attempts = 0
|
host_attempts = 0
|
||||||
|
|
||||||
@@ -189,7 +424,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
|
|||||||
config = _config(
|
config = _config(
|
||||||
host_id="",
|
host_id="",
|
||||||
token="",
|
token="",
|
||||||
enrollment_token="one-time-token",
|
|
||||||
enrollment_managed=True,
|
enrollment_managed=True,
|
||||||
)
|
)
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@@ -209,7 +443,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
|
|||||||
client.config = _config(
|
client.config = _config(
|
||||||
host_id=host.host_id,
|
host_id=host.host_id,
|
||||||
token="host-token-" + ("x" * 40),
|
token="host-token-" + ("x" * 40),
|
||||||
enrollment_token="one-time-token",
|
|
||||||
enrollment_managed=True,
|
enrollment_managed=True,
|
||||||
)
|
)
|
||||||
device = client.enroll_device(
|
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 device.device_id == "device-cloud-a"
|
||||||
assert len(requests) == 3
|
assert len(requests) == 3
|
||||||
assert requests[0].content == requests[1].content
|
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))
|
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()
|
||||||
@@ -11,24 +11,36 @@ from host_agent.config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
BASE_ENV = {
|
def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
|
||||||
"HOST_AGENT_HOST_ID": "host-a",
|
config = load_host_agent_config({})
|
||||||
"HOST_AGENT_TOKEN": "secret",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
assert config == HostAgentConfig(
|
||||||
def test_load_host_agent_config_uses_local_network_defaults() -> None:
|
control_plane_url="https://amcp.home.jerryyan.top",
|
||||||
assert load_host_agent_config(BASE_ENV) == HostAgentConfig(
|
enrollment_managed=True,
|
||||||
control_plane_url="http://127.0.0.1:8001",
|
|
||||||
host_id="host-a",
|
|
||||||
token="secret",
|
|
||||||
)
|
)
|
||||||
|
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:
|
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
|
||||||
config = load_host_agent_config(
|
config = load_host_agent_config(
|
||||||
{
|
{
|
||||||
**BASE_ENV,
|
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example/v1/",
|
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example/v1/",
|
||||||
"HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS": "10",
|
"HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS": "10",
|
||||||
"HOST_AGENT_POLL_TIMEOUT_SECONDS": "15",
|
"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
|
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"
|
identity_path = tmp_path / "host_identity.json"
|
||||||
config = load_host_agent_config(
|
config = load_host_agent_config(
|
||||||
{
|
{
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
|
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
|
||||||
"HOST_AGENT_ENROLLMENT_TOKEN": "one-time-token",
|
|
||||||
"HOST_AGENT_IDENTITY_PATH": str(identity_path),
|
"HOST_AGENT_IDENTITY_PATH": str(identity_path),
|
||||||
"HOST_AGENT_DISPLAY_NAME": "Edge Mac",
|
"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.host_id == ""
|
||||||
assert config.token == ""
|
assert config.token == ""
|
||||||
assert config.enrollment_token == "one-time-token"
|
|
||||||
assert config.identity_path == identity_path
|
assert config.identity_path == identity_path
|
||||||
assert config.enrollment_managed is True
|
assert config.enrollment_managed is True
|
||||||
assert config.display_name == "Edge Mac"
|
assert config.display_name == "Edge Mac"
|
||||||
assert "one-time-token" not in repr(config)
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_identity_state_allows_restart_without_enrollment_token(
|
def test_static_credential_environment_variables_are_ignored() -> None:
|
||||||
tmp_path,
|
config = load_host_agent_config(
|
||||||
) -> None:
|
{
|
||||||
identity_path = tmp_path / "host_identity.json"
|
"HOST_AGENT_HOST_ID": "legacy-host",
|
||||||
identity_path.write_text("{}", encoding="utf-8")
|
"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.host_id == ""
|
||||||
|
assert config.token == ""
|
||||||
assert config.identity_path == Path(identity_path)
|
|
||||||
assert config.enrollment_managed is True
|
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(
|
@pytest.mark.parametrize(
|
||||||
"overrides",
|
"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_CONTROL_PLANE_URL": "ftp://cloud.example"},
|
||||||
{"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"},
|
{"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"},
|
||||||
{
|
{
|
||||||
@@ -92,4 +121,133 @@ def test_load_host_agent_config_rejects_invalid_values(
|
|||||||
overrides: dict[str, str],
|
overrides: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
with pytest.raises(HostAgentConfigurationError):
|
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() == []
|
||||||
@@ -10,12 +10,12 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from cloud.auth import BearerCredential
|
from cloud.auth import digest_token
|
||||||
from cloud.control_config import CloudControlConfig
|
from cloud.control_config import CloudControlConfig
|
||||||
from cloud.sdk.client import CloudClient
|
from cloud.sdk.client import CloudClient
|
||||||
from cloud.scheduler import TaskConstraints
|
from cloud.scheduler import TaskConstraints
|
||||||
from cloud_api.app import create_app
|
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 device.manager import DeviceManager
|
||||||
from driver.base import Driver
|
from driver.base import Driver
|
||||||
from host_agent.assignment import AssignmentExecutionResult, AssignmentExecutor
|
from host_agent.assignment import AssignmentExecutionResult, AssignmentExecutor
|
||||||
@@ -47,6 +47,9 @@ class FakeDriver(Driver):
|
|||||||
def tap(self, x: float, y: float) -> None:
|
def tap(self, x: float, y: float) -> None:
|
||||||
self.calls.append(("tap", (x, y)))
|
self.calls.append(("tap", (x, y)))
|
||||||
|
|
||||||
|
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def swipe(
|
def swipe(
|
||||||
self,
|
self,
|
||||||
start_x: float,
|
start_x: float,
|
||||||
@@ -57,6 +60,14 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return 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:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -79,23 +90,6 @@ class FakeDriver(Driver):
|
|||||||
return None
|
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:
|
def _config(host_id: str) -> HostAgentConfig:
|
||||||
return HostAgentConfig(
|
return HostAgentConfig(
|
||||||
control_plane_url="http://control.test",
|
control_plane_url="http://control.test",
|
||||||
@@ -120,10 +114,18 @@ async def _control_plane(
|
|||||||
scheduler_interval_seconds=60,
|
scheduler_interval_seconds=60,
|
||||||
lease_reaper_interval_seconds=60,
|
lease_reaper_interval_seconds=60,
|
||||||
lease_duration_seconds=lease_duration_seconds,
|
lease_duration_seconds=lease_duration_seconds,
|
||||||
credentials=tuple(_credential(host_id) for host_id in host_ids),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
async with app.router.lifespan_context(app):
|
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
|
yield app
|
||||||
|
|
||||||
|
|
||||||
@@ -162,12 +164,22 @@ class _RecordingTransport(httpx.AsyncBaseTransport):
|
|||||||
|
|
||||||
|
|
||||||
async def _sync_fake_device(
|
async def _sync_fake_device(
|
||||||
|
app,
|
||||||
client: HostAgentClient,
|
client: HostAgentClient,
|
||||||
host_id: str,
|
host_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
*,
|
*,
|
||||||
driver_type: str = "wda",
|
driver_type: str = "wda",
|
||||||
) -> FakeDriver:
|
) -> 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()
|
driver = FakeDriver()
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
manager.register_device(
|
manager.register_device(
|
||||||
@@ -248,7 +260,7 @@ def test_one_host_executes_assignment_through_outbound_protocol(tmp_path) -> Non
|
|||||||
paths: list[str] = []
|
paths: list[str] = []
|
||||||
async with _control_plane(tmp_path / "one-host.sqlite3", "host-a") as app:
|
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:
|
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(
|
task_id = app.state.cloud_services.scheduler.submit(
|
||||||
goal="open settings"
|
goal="open settings"
|
||||||
)
|
)
|
||||||
@@ -275,7 +287,7 @@ def test_nat_style_host_requires_only_outbound_requests(tmp_path) -> None:
|
|||||||
paths: list[str] = []
|
paths: list[str] = []
|
||||||
async with _control_plane(tmp_path / "outbound-only.sqlite3", "host-a") as app:
|
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:
|
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 await client.claim() is None
|
||||||
|
|
||||||
assert paths == [
|
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-a") as client_a,
|
||||||
_host_client(app, "host-b") as client_b,
|
_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(
|
await _sync_fake_device(
|
||||||
|
app,
|
||||||
client_b,
|
client_b,
|
||||||
"host-b",
|
"host-b",
|
||||||
"device-b",
|
"device-b",
|
||||||
@@ -331,7 +344,7 @@ def test_control_plane_restart_preserves_dispatched_assignment(tmp_path) -> None
|
|||||||
assignment = None
|
assignment = None
|
||||||
async with _control_plane(database_path, "host-a") as first_app:
|
async with _control_plane(database_path, "host-a") as first_app:
|
||||||
async with _host_client(first_app, "host-a") as client:
|
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")
|
task_id = first_app.state.cloud_services.scheduler.submit(goal="resume")
|
||||||
first_app.state.cloud_services.scheduler.assign()
|
first_app.state.cloud_services.scheduler.assign()
|
||||||
assignment = await client.claim()
|
assignment = await client.claim()
|
||||||
@@ -351,7 +364,7 @@ def test_host_agent_restart_reuses_active_lease(tmp_path) -> None:
|
|||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
async with _control_plane(tmp_path / "agent-restart.sqlite3", "host-a") as app:
|
async with _control_plane(tmp_path / "agent-restart.sqlite3", "host-a") as app:
|
||||||
async with _host_client(app, "host-a") as first_client:
|
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")
|
task_id = app.state.cloud_services.scheduler.submit(goal="resume host")
|
||||||
app.state.cloud_services.scheduler.assign()
|
app.state.cloud_services.scheduler.assign()
|
||||||
assignment = await first_client.claim()
|
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,
|
lease_duration_seconds=0.2,
|
||||||
) as app:
|
) as app:
|
||||||
async with _host_client(app, "host-a") as client:
|
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")
|
task_id = app.state.cloud_services.scheduler.submit(goal="expire")
|
||||||
app.state.cloud_services.scheduler.assign()
|
app.state.cloud_services.scheduler.assign()
|
||||||
assignment = await client.claim()
|
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,
|
scheduler_interval_seconds=60,
|
||||||
lease_reaper_interval_seconds=60,
|
lease_reaper_interval_seconds=60,
|
||||||
lease_duration_seconds=30,
|
lease_duration_seconds=30,
|
||||||
credentials=(_public_credential(), _credential("host-a")),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
driver = FakeDriver()
|
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")
|
manager.register_device("device-a", lambda: driver, status="idle")
|
||||||
|
|
||||||
with TestClient(app) as http_client:
|
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(
|
cloud_client = CloudClient(
|
||||||
"http://testserver",
|
"http://testserver",
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
token="public-token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
@@ -428,9 +468,7 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) ->
|
|||||||
heartbeat.connect_devices()
|
heartbeat.connect_devices()
|
||||||
await heartbeat.sync_once()
|
await heartbeat.sync_once()
|
||||||
|
|
||||||
successful_task_id = cloud_client.submit_task(goal="tap screen")[
|
successful_task_id = cloud_client.submit_task(goal="tap screen")["task_id"]
|
||||||
"task_id"
|
|
||||||
]
|
|
||||||
app.state.cloud_services.scheduler.assign()
|
app.state.cloud_services.scheduler.assign()
|
||||||
successful_assignment = await host_client.claim()
|
successful_assignment = await host_client.claim()
|
||||||
assert successful_assignment is not None
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from device.manager import DeviceManager
|
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 host_agent.execution import create_execution_factories
|
||||||
|
from runtime.ai_planner import AIPlanner
|
||||||
|
from runtime.planner import Planner
|
||||||
from runtime.task import TaskRunner
|
from runtime.task import TaskRunner
|
||||||
from workflow.runner import WorkflowRunner
|
from workflow.runner import WorkflowRunner
|
||||||
from workflow.store import WorkflowStore
|
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:
|
def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) -> None:
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
workflow_store = WorkflowStore(tmp_path / "workflows.sqlite3")
|
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)
|
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:
|
def test_runtime_owned_packages_do_not_import_host_or_cloud_concerns() -> None:
|
||||||
root = Path(__file__).resolve().parents[3]
|
root = Path(__file__).resolve().parents[3]
|
||||||
forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent")
|
forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent")
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import asyncio
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from cloud.internal_api.models import HeartbeatResponse
|
from cloud.internal_api.models import HeartbeatResponse
|
||||||
|
from cloud.internal_api.models import HostGovernancePolicyModel
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
|
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:
|
class ConnectableDriver:
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def screenshot(self) -> bytes:
|
||||||
|
return b"ok"
|
||||||
|
|
||||||
|
|
||||||
def _config() -> HostAgentConfig:
|
def _config() -> HostAgentConfig:
|
||||||
return HostAgentConfig(
|
return HostAgentConfig(
|
||||||
@@ -57,7 +64,9 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
|
|||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
class FakeClient:
|
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])
|
calls.append([device.device_id for device in devices])
|
||||||
return HeartbeatResponse(
|
return HeartbeatResponse(
|
||||||
host_id="host-a",
|
host_id="host-a",
|
||||||
@@ -81,3 +90,185 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
assert calls == [["device-a"], ["device-a"], ["device-a"]]
|
assert calls == [["device-a"], ["device-a"], ["device-a"]]
|
||||||
assert manager.status("device-a") == "busy"
|
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()
|
||||||
@@ -29,13 +29,16 @@ def test_lease_renews_while_execution_is_active() -> None:
|
|||||||
renewed = asyncio.Event()
|
renewed = asyncio.Event()
|
||||||
|
|
||||||
class BlockingExecutor:
|
class BlockingExecutor:
|
||||||
def execute(self, assignment, *, should_stop=None):
|
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||||
execution_started.set()
|
execution_started.set()
|
||||||
release_execution.wait(timeout=2)
|
release_execution.wait(timeout=2)
|
||||||
return AssignmentExecutionResult(status="done")
|
return AssignmentExecutionResult(status="done")
|
||||||
|
|
||||||
|
def latest_progress(self):
|
||||||
|
return None
|
||||||
|
|
||||||
class RenewingClient:
|
class RenewingClient:
|
||||||
async def renew(self, assignment):
|
async def renew(self, assignment, *, progress=None):
|
||||||
renewed.set()
|
renewed.set()
|
||||||
return LeaseRenewalResponse(
|
return LeaseRenewalResponse(
|
||||||
status="renewed",
|
status="renewed",
|
||||||
@@ -58,13 +61,13 @@ def test_lease_renews_while_execution_is_active() -> None:
|
|||||||
asyncio.run(scenario())
|
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:
|
async def scenario() -> None:
|
||||||
first_action_started = Event()
|
first_action_started = Event()
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
class CooperativeExecutor:
|
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
|
assert should_stop is not None
|
||||||
actions.append("first")
|
actions.append("first")
|
||||||
first_action_started.set()
|
first_action_started.set()
|
||||||
@@ -73,13 +76,66 @@ def test_stale_lease_stops_later_interruptible_actions() -> None:
|
|||||||
Event().wait(0.001)
|
Event().wait(0.001)
|
||||||
if not should_stop():
|
if not should_stop():
|
||||||
actions.append("second")
|
actions.append("second")
|
||||||
|
reason = stop_reason() if stop_reason is not None else None
|
||||||
return AssignmentExecutionResult(
|
return AssignmentExecutionResult(
|
||||||
status="failed",
|
status="cancelled" if reason and "cancel" in reason else "failed",
|
||||||
failure_reason="execution interrupted",
|
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:
|
class StaleClient:
|
||||||
async def renew(self, assignment):
|
async def renew(self, assignment, *, progress=None):
|
||||||
assert await asyncio.to_thread(first_action_started.wait, 1)
|
assert await asyncio.to_thread(first_action_started.wait, 1)
|
||||||
raise StaleLeaseError(409, "stale lease")
|
raise StaleLeaseError(409, "stale lease")
|
||||||
|
|
||||||
@@ -102,11 +158,14 @@ def test_renewal_loop_exits_when_execution_finishes() -> None:
|
|||||||
renew_calls = 0
|
renew_calls = 0
|
||||||
|
|
||||||
class ImmediateExecutor:
|
class ImmediateExecutor:
|
||||||
def execute(self, assignment, *, should_stop=None):
|
def execute(self, assignment, *, should_stop=None, stop_reason=None):
|
||||||
return AssignmentExecutionResult(status="done")
|
return AssignmentExecutionResult(status="done")
|
||||||
|
|
||||||
|
def latest_progress(self):
|
||||||
|
return None
|
||||||
|
|
||||||
class CountingClient:
|
class CountingClient:
|
||||||
async def renew(self, assignment):
|
async def renew(self, assignment, *, progress=None):
|
||||||
nonlocal renew_calls
|
nonlocal renew_calls
|
||||||
renew_calls += 1
|
renew_calls += 1
|
||||||
return LeaseRenewalResponse(
|
return LeaseRenewalResponse(
|
||||||
@@ -133,7 +192,7 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
|
|||||||
execution_started = Event()
|
execution_started = Event()
|
||||||
|
|
||||||
class CooperativeExecutor:
|
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
|
assert should_stop is not None
|
||||||
execution_started.set()
|
execution_started.set()
|
||||||
while not should_stop():
|
while not should_stop():
|
||||||
@@ -143,8 +202,11 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
|
|||||||
failure_reason="execution interrupted",
|
failure_reason="execution interrupted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def latest_progress(self):
|
||||||
|
return None
|
||||||
|
|
||||||
class RenewingClient:
|
class RenewingClient:
|
||||||
async def renew(self, assignment):
|
async def renew(self, assignment, *, progress=None):
|
||||||
return LeaseRenewalResponse(
|
return LeaseRenewalResponse(
|
||||||
status="renewed",
|
status="renewed",
|
||||||
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
|
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 cloud.internal_api.models import AssignmentModel, TerminalResultResponse
|
||||||
from host_agent.assignment import AssignmentExecutionResult
|
from host_agent.assignment import AssignmentExecutionResult
|
||||||
from host_agent.processor import AssignmentProcessor
|
from host_agent.processor import AssignmentProcessor
|
||||||
|
from host_agent.status import AgentStatusTracker
|
||||||
|
|
||||||
|
|
||||||
def _assignment() -> AssignmentModel:
|
def _assignment() -> AssignmentModel:
|
||||||
@@ -85,3 +86,101 @@ def test_processor_preserves_runtime_failure_reason() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
asyncio.run(scenario())
|
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
@@ -1,95 +1,63 @@
|
|||||||
# Cloud Console
|
# Cloud Console
|
||||||
|
|
||||||
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
|
Vue 3 + Vite single-page app for the Cloud Control Plane (`apps/cloud-api`).
|
||||||
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
|
The primary flow is a Cloud user account: username/password login creates an
|
||||||
bearer token; the console stores it in `sessionStorage`, attaches
|
expiring, revocable `HttpOnly` session cookie, while the frontend sends the
|
||||||
`Authorization: Bearer <token>` to every request, and clears it whenever the
|
separate CSRF cookie value on writes. The browser never stores the session
|
||||||
Cloud API responds `401` or `403`.
|
secret in JavaScript.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 20+ (matching the existing `console/` SPA project)
|
- Node.js 20+
|
||||||
- A running Cloud API (`apps/cloud-api`) reachable from your browser
|
- A current Cloud API database migration and at least one administrator created
|
||||||
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
|
with `device-cloud-admin users create ...`
|
||||||
what you intend to do from the console. Recommended least-privilege set:
|
- HTTPS for production: `CLOUD_SESSION_COOKIE_SECURE=true` is required in a
|
||||||
- `tasks:read` — task list and attempt history views
|
production Cloud API. Terminate TLS at the origin serving `/console/`.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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
|
- `viewer`: task, device/host, and plugin read views
|
||||||
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
|
- `operator`: viewer access plus task submission APIs
|
||||||
that includes the exact origin your dev server prints (scheme + host + port —
|
- `admin`: all API scopes
|
||||||
no trailing slash):
|
|
||||||
|
|
||||||
```bash
|
Administrators can use **Users & limits** to manage accounts, restrict task
|
||||||
# Example: allow the default Vite dev origin
|
submission to explicit Host/Device targets, configure Host self-submission and
|
||||||
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
|
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 —
|
Task readers can inspect full per-step LLM interaction history for
|
||||||
the allow-list only says which browser origins may send them.
|
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
|
```bash
|
||||||
cd cloud-console
|
cd cloud-console
|
||||||
cp .env.example .env.local
|
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 install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
|
For a Vite origin such as `http://127.0.0.1:5173`, configure the API with the
|
||||||
bearer token, and the task/device/host/plugin dashboards become available.
|
exact origin and disable secure cookies only in local/test mode:
|
||||||
|
|
||||||
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
|
|
||||||
(defaults to `http://127.0.0.1:8001`).
|
|
||||||
|
|
||||||
## Build for production
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # type-checks with vue-tsc, then emits dist/
|
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
|
||||||
npm run preview # serves the built bundle locally
|
export CLOUD_SESSION_COOKIE_SECURE=false
|
||||||
```
|
```
|
||||||
|
|
||||||
`dist/` is a static bundle — host it behind any static file server or CDN and
|
The Console uses `credentials: include`. `401` returns to the login screen;
|
||||||
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
|
`403` remains an authorization error so an otherwise valid session is retained.
|
||||||
time.
|
|
||||||
|
|
||||||
## Token handling
|
## Production
|
||||||
|
|
||||||
- The token is held in `sessionStorage` only. Closing the tab discards it.
|
`npm run build` type-checks and creates `dist/`. The repository Dockerfile
|
||||||
- Every API request attaches `Authorization: Bearer <token>` and targets only
|
already builds this bundle into `/app/console-static` and configures the Cloud
|
||||||
the configured `VITE_CLOUD_API_BASE_URL`.
|
API to serve it at the same-origin `/console/` route. No CORS configuration is
|
||||||
- A `401`/`403` response clears the stored token and returns the operator to
|
required in that deployment shape. Use `device-cloud-admin` for account
|
||||||
the token-entry screen with the API's error detail.
|
provisioning and recovery.
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|||||||
Generated
+986
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/vue": "^1.23.0",
|
"@lucide/vue": "^1.23.0",
|
||||||
@@ -15,8 +16,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"jsdom": "^27.1.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.1.3",
|
"vite": "^8.1.3",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.3.6"
|
"vue-tsc": "^3.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-59
@@ -3,114 +3,178 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
|
|||||||
import type { Component } from "vue";
|
import type { Component } from "vue";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
BookOpen,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
SlidersHorizontal,
|
||||||
|
UsersRound,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import {
|
import {
|
||||||
TOKEN_INVALID_EVENT,
|
AUTH_INVALID_EVENT,
|
||||||
clearStoredToken,
|
getCurrentUser,
|
||||||
getStoredToken,
|
logout,
|
||||||
} from "./api";
|
} 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 TasksView from "./views/TasksView.vue";
|
||||||
import DevicesView from "./views/DevicesView.vue";
|
import DevicesView from "./views/DevicesView.vue";
|
||||||
import PluginsView from "./views/PluginsView.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";
|
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
|
||||||
|
|
||||||
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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeView = ref<ViewId>("tasks");
|
const activeView = ref<ViewId>("tasks");
|
||||||
const tokenRejectedMessage = ref("");
|
const currentUser = ref<CloudUser | null>(null);
|
||||||
const hasToken = ref(false);
|
const loading = ref(true);
|
||||||
|
const authMessage = ref("");
|
||||||
|
|
||||||
function refreshTokenState() {
|
const canAdminPlugins = computed(
|
||||||
hasToken.value = getStoredToken() !== null;
|
() =>
|
||||||
}
|
currentUser.value?.scopes.includes("*") ||
|
||||||
|
currentUser.value?.scopes.includes("plugins:admin"),
|
||||||
function onTokenInvalid() {
|
);
|
||||||
hasToken.value = false;
|
const canSubmitTasks = computed(
|
||||||
tokenRejectedMessage.value =
|
() =>
|
||||||
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
|
currentUser.value?.scopes.includes("*") ||
|
||||||
}
|
currentUser.value?.scopes.includes("tasks:submit"),
|
||||||
|
);
|
||||||
function onStorage(event: StorageEvent) {
|
const canAdminUsers = computed(
|
||||||
if (event.key === null) {
|
() => Boolean(
|
||||||
// Tab-wide sessionStorage clear (some browsers fire this on logout).
|
currentUser.value?.scopes.includes("*") ||
|
||||||
refreshTokenState();
|
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() {
|
async function onAuthenticated() {
|
||||||
clearStoredToken();
|
authMessage.value = "";
|
||||||
hasToken.value = false;
|
await initializeAuthentication();
|
||||||
tokenRejectedMessage.value = "";
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
refreshTokenState();
|
void initializeAuthentication();
|
||||||
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
|
window.addEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
||||||
window.addEventListener("storage", onStorage as EventListener);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
|
window.removeEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
||||||
window.removeEventListener("storage", onStorage as EventListener);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeComponent = computed(() => {
|
const activeComponent = computed(() => {
|
||||||
switch (activeView.value) {
|
switch (activeView.value) {
|
||||||
case "tasks":
|
|
||||||
return TasksView;
|
|
||||||
case "devices":
|
case "devices":
|
||||||
return DevicesView;
|
return DevicesView;
|
||||||
case "plugins":
|
case "plugins":
|
||||||
return PluginsView;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TokenScreen
|
<div v-if="loading" class="token-screen"><p>Checking session…</p></div>
|
||||||
v-if="!hasToken"
|
<LoginScreen v-else-if="!isAuthenticated" :message="authMessage" @authenticated="onAuthenticated" />
|
||||||
:rejection-message="tokenRejectedMessage"
|
<PasswordChangeScreen
|
||||||
@submitted="onTokenSubmitted"
|
v-else-if="mustChangePassword"
|
||||||
|
@changed="onPasswordChanged"
|
||||||
|
@sign-out="signOut"
|
||||||
/>
|
/>
|
||||||
<div v-else class="app-shell">
|
<div v-else class="app-shell">
|
||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<h1>
|
<h1><Boxes :size="14" /> Cloud Console</h1>
|
||||||
<Boxes :size="14" />
|
|
||||||
Cloud Console
|
|
||||||
</h1>
|
|
||||||
<button
|
<button
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:class="{ active: activeView === item.id }"
|
:class="{ active: activeView === item.id }"
|
||||||
@click="activeView = item.id"
|
@click="activeView = item.id"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="14" />
|
<component :is="item.icon" :size="14" /> {{ item.label }}
|
||||||
{{ item.label }}
|
|
||||||
</button>
|
</button>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<button @click="signOut">
|
<div class="dim">{{ currentUserLabel }}</div>
|
||||||
<LogOut :size="14" />
|
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
|
||||||
Clear token
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
<main class="app-main">
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user