Files
agentic-mobile-control/docs/CLOUD_DEPLOYMENT.md
T
q792602257 efeb3eb926
Tests / Test passed: 581
Implement edge-host-self-enrollment
Host Agent:
- One-time local operator account bootstrap (PBKDF2-HMAC-SHA256, atomic
  0600-permission write) gating the daemon's first unattended start via a
  new `setup` CLI subcommand.
- Default control-plane URL now https://amcp.home.jerryyan.top (env var
  override unchanged).
- Enrollment no longer requires a pre-issued token; falls back to
  zero-token self-service enrollment when none is configured.

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

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

Verification: 494 non-integration tests pass; openspec validate --strict
passes. PostgreSQL-backed contract tests and full manual end-to-end
verification were not run (no Postgres/Docker or reachable cloud-api in
this environment); noted as unchecked in tasks.md 7.2/7.4.
2026-07-13 18:30:49 +08:00

430 lines
18 KiB
Markdown

# Cloud Control Plane Deployment
This guide covers the deployable Cloud API and outbound Device Host Agent.
Run commands from the repository root after synchronizing the uv workspace:
```bash
uv sync --locked --all-packages
```
## Local SQLite
SQLite is intended for local development and tests with one Cloud API process.
Configure public and host credentials even in local mode because anonymous
development access cannot authorize a host identity.
```powershell
$env:CLOUD_ENVIRONMENT = "local"
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
$env:CLOUD_PUBLIC_CREDENTIALS_JSON = '[{"principal_id":"local-sdk","token":"replace-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]'
$env:CLOUD_HOST_CREDENTIALS_JSON = '[{"principal_id":"local-host","token":"replace-host-token","scopes":[],"host_id":"host-local"}]'
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
```
In a second terminal, start the Host Agent with the matching host identity and
token:
```powershell
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
$env:HOST_AGENT_HOST_ID = "host-local"
$env:HOST_AGENT_TOKEN = "replace-host-token"
uv run --package device-host-agent device-host-agent
```
At startup, the Host Agent loads device registrations from
`tasks/device_config.sqlite3`, the same `DeviceConfigStore` used by the local
Runtime console API. Register or update devices before starting the Host Agent,
then restart it to reload changes. In Compose,
`HOST_AGENT_TASKS_PATH` selects the host directory mounted at `/app/tasks`; it
defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
## Managed Edge Enrollment
New edge installations do not need a pre-coordinated Host or device ID. The
Cloud API accepts configured one-time enrollment credentials:
```powershell
$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
```
On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
the enrollment token only for the first successful enrollment:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
uv run --package device-host-agent device-host-agent
```
Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
an instance identifier and long-lived random Host secret. The cloud consumes
the enrollment token, assigns `host_id`, stores only credential digests, and
returns the assigned ID. The Host Agent then enrolls each record from
`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
database, connects the resulting devices, and starts heartbeat/claim loops.
Keep the identity file and device configuration database on persistent edge
storage with permissions limited to the service account. The identity file is
a bearer secret: do not put it in an image, repository, log, or general backup.
After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
environment. An intact identity file is sufficient for restart; if only a
device mapping is lost, device enrollment reconstructs the same cloud ID.
Enrollment tokens are one-time even when they remain in Cloud API environment
configuration: their consumed digest is stored in the database. Reusing a token
for another edge instance returns a conflict. Create a distinct token for every
edge installation.
Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
the previous legacy behavior, including locally selected device IDs. This is
the rollback and staged-migration path for existing deployments.
## Self-Service Edge Enrollment (Zero-Token)
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
default; override the environment variable for local/dev/test runs pointed at
a different Cloud API.
When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
the Host Agent enrolls with no bearer credential at all. The Cloud API only
accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
(default `false`); a configured enrollment token, if presented, always takes
priority over self-service. This trades away any approval step: **any caller
that can reach the control-plane URL can register itself as a new Host.**
There is no rate limiting or throttling on this path by design — the intended
mitigation is network-perimeter control (firewall/reverse-proxy access to the
URL), not an in-process limiter. Only enable the flag on a deployment where
that network boundary is enforced.
Before the Host Agent's first unattended start, create the one-time local
operator account interactively:
```bash
uv run --package device-host-agent device-host-agent setup
```
This prompts for a username/password and writes a PBKDF2-hashed credential
file to `HOST_AGENT_LOCAL_ACCOUNT_PATH` (default
`tasks/host_local_account.json`), gating only this first-run bootstrap step —
it is not re-checked on subsequent unattended restarts. Running the daemon's
default command without a controlling terminal before this file exists fails
fast with a message naming the `setup` step, instead of hanging on a prompt
no one can answer.
Running under Compose, create the account once before `docker compose up`:
```bash
docker compose run --rm host-agent device-host-agent setup
docker compose up -d
```
## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the
resulting `.env` file outside version control. The Compose stack contains
PostgreSQL, the Cloud API, and one Host Agent:
```bash
docker compose up --build -d
docker compose ps
```
The Cloud API container waits for PostgreSQL, runs the committed Alembic
migrations, then starts the API on port `8001`. Readiness is available at:
```text
GET http://127.0.0.1:8001/health/ready
```
## Deploying A Jenkins-Built Image
`docker compose up --build` above builds the image from source on the target
host. For environments that pull a pre-built image instead, `Jenkinsfile` runs
the pytest suite (`-m "not integration"`), builds the image from the same
`Dockerfile`, smoke-tests both console scripts (`device-cloud-api --help`,
`device-host-agent --help`), and pushes `<REGISTRY>/<IMAGE_NAME>:<BUILD_NUMBER>-<git short sha>`
plus `:latest` to the configured registry.
`compose.deploy.yaml` is the same three-service stack as `compose.yaml`
except `cloud-api` and `host-agent` reference `image:` instead of `build:`.
Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag
Jenkins published, then deploy without a local build step:
```bash
docker compose -f compose.deploy.yaml pull
docker compose -f compose.deploy.yaml up -d
docker compose -f compose.deploy.yaml ps
```
For a deployment that manages processes outside Compose, apply migrations
before starting the new Cloud API version:
```powershell
$env:CLOUD_DATABASE_URL = "postgresql+psycopg://USER:PASSWORD@HOST:5432/device_cloud"
uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini upgrade head
uv run --package device-cloud-api device-cloud-api --host 0.0.0.0 --port 8001
```
Production startup requires `CLOUD_ENVIRONMENT=production`, a current schema,
and at least one configured bearer credential.
## Credentials And Scopes
`CLOUD_PUBLIC_CREDENTIALS_JSON` is a JSON array of public API principals. Grant
only the scopes required by each integration:
- `tasks:submit`: submit tasks.
- `tasks:read`: read task status and failure metadata.
- `pool:read`: list hosts and devices.
- `plugins:read`: list installed plugin registrations.
- `plugins:admin`: register installed plugin entry points.
`CLOUD_HOST_CREDENTIALS_JSON` contains Host Agent principals. Every entry must
include exactly one `host_id`; its token is valid only for heartbeat, claim,
renewal, and result operations for that host.
`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
`principal_id` and `token`. These credentials cannot submit tasks, read the
pool, or operate as a Host; they can only create one durable Host binding.
Use high-entropy values generated by the deployment secret manager.
`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
Host enrollment requests with no bearer token at all — see
[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
Do not place bearer tokens in command history, image layers, Compose files, or
logs. Use environment injection or the deployment platform's secret manager.
Rotate a token by deploying the updated Cloud API credential set and Host Agent
configuration together.
Dynamically enrolled Host credentials are stored as digests in the cloud
database. This release exposes repository-level revocation rather than a public
administration endpoint. An operator with database deployment access can revoke
a Host without deleting its task history:
```bash
export HOST_ID="host-..."
uv run --package device-cloud-platform python - <<'PY'
import os
from cloud.database import CloudDatabase
from core.models import utc_now
database = CloudDatabase(os.environ["CLOUD_DATABASE_URL"], create_schema=False)
try:
changed = database.repository.revoke_enrolled_host(
os.environ["HOST_ID"],
revoked_at=utc_now(),
)
print("revoked" if changed else "not an enrolled host")
finally:
database.close()
PY
```
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders task history, devices, hosts, plugins, and the user directory. Human
operators sign in with a username and password; the Cloud API creates an
expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
cookie/header for writes. Existing bearer tokens remain available through the
Console's explicit **Use API token** action and for SDK, Host Agent, and
automation compatibility.
### HTTPS and session configuration
`CLOUD_ENVIRONMENT=production` requires `CLOUD_SESSION_COOKIE_SECURE=true`.
Terminate TLS at a reverse proxy and open the same-origin Console through HTTPS,
for example `https://cloud.example.com/console/`. Direct `http://host:8001`
access is for local/test mode only; it cannot retain production login cookies.
The defaults are an 8-hour idle session TTL, 7-day absolute TTL, and a temporary
block after five failed logins in a 15-minute username/client-address window:
```text
CLOUD_USER_SESSION_IDLE_SECONDS=28800
CLOUD_USER_SESSION_ABSOLUTE_SECONDS=604800
CLOUD_LOGIN_FAILURE_LIMIT=5
CLOUD_LOGIN_FAILURE_WINDOW_SECONDS=900
CLOUD_LOGIN_BLOCK_SECONDS=900
CLOUD_SESSION_COOKIE_SECURE=true
CLOUD_TRUST_PROXY_HEADERS=false
```
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
`X-Forwarded-For` before requests reach the Cloud API.
### Create and recover administrator accounts
After migrations and Cloud API startup, create the first account interactively:
```bash
docker compose exec cloud-api \
device-cloud-admin users create \
--username admin --display-name "Cloud Administrator" --role admin
```
The command prompts twice for the password, so it does not enter shell history,
Compose configuration, process arguments, logs, or container inspection output.
Recovery commands are also interactive:
```bash
docker compose exec cloud-api device-cloud-admin users reset-password --username admin
docker compose exec cloud-api device-cloud-admin users enable --username admin
docker compose exec cloud-api device-cloud-admin users revoke-sessions --username admin
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
submits tasks; `admin` has unrestricted Cloud API access and manages users.
Administrators create users, reset passwords, change roles, disable accounts,
and revoke sessions from the **Users** Console view. New and reset users must
change their temporary password before accessing other resources, and the API
will not disable or demote the last enabled administrator.
### Configure the CORS allow-list
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin (scheme + host + port, no trailing slash) the
operator's browser will load the console from:
```bash
# Allow a local Vite dev server
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
# Or a deployed origin
export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
```
Restart the Cloud API after changing this env. The middleware is added only
when the allow-list is non-empty — existing deployments see no behavior change
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
supported because browser sessions are credentialed.
### Run the console
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. For local development
set `CLOUD_SESSION_COOKIE_SECURE=false`, then open the dev URL and sign in with
a user account. The Console sends credentialed requests and attaches CSRF proof
to writes.
For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
### Same-origin deployment (baked into the Cloud API image)
The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
for example `https://cloud.example.com/` directly — no separate dev server, no
static host, and no CORS allow-list are needed because the SPA and API share one
origin.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
Docker Hub / ghcr.io / npmjs.org / deb.debian.org. Blank any of them to fall
back to the upstream.
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. AI planning is disabled by
default. Configure it in the Host Agent environment when goal assignments must
use a model:
```text
AI_PLANNER_ENABLED=true
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=claude-sonnet-5
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=<secret manager reference>
```
For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. Provider credentials belong
only on the Host Agent; the Cloud API does not need them.
## Operational Limitations
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
documented single-control-plane development mode. PostgreSQL row locking makes
assignment and claim transactions safe if requests overlap, but this release
does not implement scheduler leader election or claim active-active scheduler
operation. Starting multiple Cloud API replicas would start one scheduler loop
per replica and is outside the supported deployment topology.
Device execution provides at-least-once side-effect semantics, not exactly-once
semantics. A device action can succeed immediately before the Host Agent loses
its lease or its result response, after which the control plane may retry the
task. Lease renewal and cooperative stop checks prevent later interruptible
actions where possible, but they cannot roll back an action already sent to a
device or safely terminate an in-progress synchronous driver call.
Use bounded attempts, inspect task attempt and failure metadata, and design
device workflows to tolerate repeated actions when the target operation allows
it. Do not use this release for operations that require a transactional
exactly-once guarantee across the cloud database and an external device.
## Shutdown And Rollback
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
later cooperative actions, finish terminal reporting where the lease remains
valid, and attempt a final heartbeat. Stop the Cloud API after Host Agents have
exited, then stop PostgreSQL only if the database itself is being maintained:
```bash
docker compose stop host-agent
docker compose stop cloud-api
docker compose stop postgres
```
For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
3. Before rolling back to a release without enrollment support, provision
temporary static Host credentials for every managed edge that must continue
operating. Stop those Host Agents and set their explicit Host ID/token.
4. If the previous application version cannot use the current schema, run the
tested downgrade while no application process is connected:
```bash
uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini downgrade -1
```
5. Restore the previous application image or checkout and start the Cloud API.
6. Verify `/health/ready`, then restart Host Agents with credentials compatible
with the restored Cloud API.
Downgrading revision 0002 removes dynamic credential bindings and durable
device enrollment mappings. It retains the revision-0001 Host heartbeat rows,
pooled devices, queued tasks, attempts, and plugins.
Do not remove the PostgreSQL volume during an application rollback. Queued and
attempt history are durable database state and should remain available to the
restored or forward-deployed control plane.