Merge master into security/bind-ports-to-localhost: keep localhost port binding and redis healthcheck

This commit is contained in:
Aaron Aronchick
2026-03-05 13:32:32 +00:00
276 changed files with 16849 additions and 8813 deletions
+4 -3
View File
@@ -12,6 +12,7 @@ POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5432
# --- backend settings (see backend/.env.example for full list) ---
# For remote access, set this to your UI origin (e.g. http://<server-ip>:3000 or https://mc.example.com).
CORS_ORIGINS=http://localhost:3000
DB_AUTO_MIGRATE=true
LOG_LEVEL=INFO
@@ -22,6 +23,6 @@ LOCAL_AUTH_TOKEN=
# --- frontend settings ---
# REQUIRED: Public URL used by the browser to reach the API.
# If this is missing/blank, frontend API calls (e.g. Activity feed) will break.
# Example (local dev / compose on your machine):
NEXT_PUBLIC_API_URL=http://localhost:8000
# Use `auto` to target the same host currently serving Mission Control on port 8000.
# Example (explicit override): NEXT_PUBLIC_API_URL=https://mc.example.com
NEXT_PUBLIC_API_URL=auto
+39
View File
@@ -0,0 +1,39 @@
# Repository Guidelines
## Project Structure & Module Organization
- `backend/`: FastAPI service. Main app code lives in `backend/app/` with API routes in `backend/app/api/`, data models in `backend/app/models/`, schemas in `backend/app/schemas/`, and service logic in `backend/app/services/`.
- `backend/migrations/`: Alembic migrations (`backend/migrations/versions/` for generated revisions).
- `backend/tests/`: pytest suite (`test_*.py` naming).
- `backend/templates/`: backend-shipped templates used by gateway flows.
- `frontend/`: Next.js app. Routes under `frontend/src/app/`, shared components under `frontend/src/components/`, utilities under `frontend/src/lib/`.
- `frontend/src/api/generated/`: generated API client; regenerate instead of editing by hand.
- `docs/`: contributor and operations docs (start at `docs/README.md`).
## Build, Test, and Development Commands
- `make setup`: install/sync backend and frontend dependencies.
- `make check`: closest CI parity run (lint, typecheck, tests/coverage, frontend build).
- `docker compose -f compose.yml --env-file .env up -d --build`: run full stack.
- Fast local loop:
- `docker compose -f compose.yml --env-file .env up -d db`
- `cd backend && uv run uvicorn app.main:app --reload --port 8000`
- `cd frontend && npm run dev`
- `make api-gen`: regenerate frontend API client (backend must be on `127.0.0.1:8000`).
## Coding Style & Naming Conventions
- Python: Black + isort + flake8 + strict mypy. Max line length is 100. Use `snake_case`.
- TypeScript/React: ESLint + Prettier. Components use `PascalCase`; variables/functions use `camelCase`.
- For intentionally unused destructured TS variables, prefix with `_` to satisfy lint config.
## Testing Guidelines
- Backend: pytest via `make backend-test`; coverage policy via `make backend-coverage` (writes `backend/coverage.xml` and `backend/coverage.json`).
- Frontend: vitest + Testing Library via `make frontend-test` (coverage in `frontend/coverage/`).
- Add or update tests whenever behavior changes.
## Commit & Pull Request Guidelines
- Follow Conventional Commits (seen in history), e.g. `feat: ...`, `fix: ...`, `docs: ...`, `test(core): ...`.
- Keep PRs focused and based on latest `master`.
- Include: what changed, why, test evidence (`make check` or targeted commands), linked issue, and screenshots/logs when UI or operator workflow changes.
## Security & Configuration Tips
- Never commit secrets. Copy from `.env.example` and keep real values in local `.env`.
- Report vulnerabilities privately via GitHub security advisories, not public issues.
+150 -28
View File
@@ -102,22 +102,18 @@ jobs:
- name: Run backend checks
env:
# Keep CI builds deterministic.
NEXT_TELEMETRY_DISABLED: "1"
AUTH_MODE: "clerk"
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
AUTH_MODE: "local"
LOCAL_AUTH_TOKEN: "ci-local-auth-token-0123456789-0123456789-0123456789x"
run: |
make backend-lint
make backend-typecheck
make backend-coverage
- name: Run frontend checks
env:
# Keep CI builds deterministic.
NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_AUTH_MODE: "clerk"
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_API_URL: "http://localhost:8000"
NEXT_PUBLIC_AUTH_MODE: "local"
run: |
make frontend-lint
make frontend-typecheck
@@ -125,7 +121,7 @@ jobs:
make frontend-build
- name: Docs quality gates (lint + relative link check)
- name: Docs quality gates
run: |
make docs-check
@@ -140,8 +136,19 @@ jobs:
frontend/coverage/**
installer:
runs-on: ubuntu-latest
name: Installer (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: [check]
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
run_linux_smoke_tests: true
run_macos_local_smoke_test: false
- os: macos-latest
run_linux_smoke_tests: false
run_macos_local_smoke_test: true
steps:
- name: Checkout
@@ -150,7 +157,52 @@ jobs:
- name: Validate installer shell syntax
run: bash -n install.sh
- name: Set up Python for macOS installer smoke test
if: ${{ matrix.run_macos_local_smoke_test }}
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv for macOS installer smoke test
if: ${{ matrix.run_macos_local_smoke_test }}
run: python -m pip install --upgrade pip uv
- name: Set up Node for macOS installer smoke test
if: ${{ matrix.run_macos_local_smoke_test }}
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Start PostgreSQL for macOS installer smoke test
if: ${{ matrix.run_macos_local_smoke_test }}
run: |
brew install postgresql@16
PG_BIN="$(brew --prefix postgresql@16)/bin"
"$PG_BIN/initdb" -D "$RUNNER_TEMP/pgdata"
"$PG_BIN/pg_ctl" -D "$RUNNER_TEMP/pgdata" -l "$RUNNER_TEMP/postgres.log" -o "-p 55432" start
"$PG_BIN/createdb" -p 55432 mission_control
- name: Installer smoke test (macOS local mode + external db)
if: ${{ matrix.run_macos_local_smoke_test }}
run: |
PGUSER="$(whoami)"
./install.sh \
--mode local \
--backend-port 18002 \
--frontend-port 13002 \
--public-host localhost \
--api-url http://localhost:18002 \
--token-mode generate \
--db-mode external \
--database-url "postgresql+psycopg://${PGUSER}@localhost:55432/mission_control" \
--start-services no
test -f .env
test -f backend/.env
test -f frontend/.env
test -f frontend/.next/BUILD_ID
- name: Installer smoke test (docker mode)
if: ${{ matrix.run_linux_smoke_tests }}
run: |
./install.sh \
--mode docker \
@@ -159,15 +211,41 @@ jobs:
--public-host localhost \
--api-url http://localhost:18000 \
--token-mode generate
curl -fsS http://127.0.0.1:18000/healthz >/dev/null
curl -fsS http://127.0.0.1:13000 >/dev/null
backend_ready=0
for _ in {1..120}; do
if curl -fsS http://127.0.0.1:18000/healthz >/dev/null; then
backend_ready=1
break
fi
sleep 2
done
frontend_ready=0
for _ in {1..120}; do
if curl -fsS http://127.0.0.1:13000 >/dev/null; then
frontend_ready=1
break
fi
sleep 2
done
if [ "$backend_ready" -ne 1 ] || [ "$frontend_ready" -ne 1 ]; then
echo "Installer docker smoke readiness failed: backend_ready=$backend_ready frontend_ready=$frontend_ready"
docker compose -f compose.yml --env-file .env ps || true
docker compose -f compose.yml --env-file .env logs --no-color --tail=200 backend db redis frontend webhook-worker || true
exit 1
fi
- name: Cleanup docker stack after docker mode
if: always()
if: ${{ always() && matrix.run_linux_smoke_tests }}
run: |
docker compose -f compose.yml --env-file .env down -v --remove-orphans || true
- name: Installer smoke test (local mode)
if: ${{ matrix.run_linux_smoke_tests }}
env:
XDG_STATE_HOME: ${{ github.workspace }}/.installer-state
run: |
./install.sh \
--mode local \
@@ -178,16 +256,66 @@ jobs:
--token-mode generate \
--db-mode docker \
--start-services yes
curl -fsS http://127.0.0.1:18001/healthz >/dev/null
curl -fsS http://127.0.0.1:13001 >/dev/null
backend_ready=0
for _ in {1..120}; do
if curl -fsS http://127.0.0.1:18001/healthz >/dev/null; then
backend_ready=1
break
fi
sleep 2
done
frontend_ready=0
for _ in {1..120}; do
if curl -fsS http://127.0.0.1:13001 >/dev/null; then
frontend_ready=1
break
fi
sleep 2
done
if [ "$backend_ready" -ne 1 ] || [ "$frontend_ready" -ne 1 ]; then
echo "Installer local smoke readiness failed: backend_ready=$backend_ready frontend_ready=$frontend_ready"
LOG_DIR="$XDG_STATE_HOME/openclaw-mission-control-install"
if [ -f "$LOG_DIR/backend.log" ]; then
echo "----- backend log (tail) -----"
tail -n 200 "$LOG_DIR/backend.log" || true
fi
if [ -f "$LOG_DIR/frontend.log" ]; then
echo "----- frontend log (tail) -----"
tail -n 200 "$LOG_DIR/frontend.log" || true
fi
exit 1
fi
- name: Cleanup local processes and docker resources
if: always()
if: ${{ always() && matrix.run_linux_smoke_tests }}
env:
XDG_STATE_HOME: ${{ github.workspace }}/.installer-state
run: |
if [ -f .install-logs/backend.pid ]; then kill "$(cat .install-logs/backend.pid)" || true; fi
if [ -f .install-logs/frontend.pid ]; then kill "$(cat .install-logs/frontend.pid)" || true; fi
LOG_DIR="$XDG_STATE_HOME/openclaw-mission-control-install"
if [ -f "$LOG_DIR/backend.pid" ]; then kill "$(cat "$LOG_DIR/backend.pid")" || true; fi
if [ -f "$LOG_DIR/frontend.pid" ]; then kill "$(cat "$LOG_DIR/frontend.pid")" || true; fi
docker compose -f compose.yml --env-file .env down -v --remove-orphans || true
- name: Cleanup macOS PostgreSQL
if: ${{ always() && matrix.run_macos_local_smoke_test }}
run: |
if ! command -v brew >/dev/null 2>&1; then
exit 0
fi
PG_PREFIX="$(brew --prefix postgresql@16 2>/dev/null || true)"
if [ -z "$PG_PREFIX" ] || [ ! -d "$PG_PREFIX" ]; then
exit 0
fi
PG_BIN="$PG_PREFIX/bin"
if [ -d "$RUNNER_TEMP/pgdata" ] && [ -x "$PG_BIN/pg_ctl" ]; then
"$PG_BIN/pg_ctl" -D "$RUNNER_TEMP/pgdata" -m fast stop || true
fi
e2e:
runs-on: ubuntu-latest
needs: [check]
@@ -219,11 +347,9 @@ jobs:
- name: Start frontend (dev server)
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_AUTH_MODE: "clerk"
NEXT_PUBLIC_API_URL: "http://localhost:8000"
NEXT_PUBLIC_AUTH_MODE: "local"
NEXT_TELEMETRY_DISABLED: "1"
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
run: |
cd frontend
npm run dev -- --hostname 0.0.0.0 --port 3000 &
@@ -236,13 +362,9 @@ jobs:
- name: Run Cypress E2E
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_AUTH_MODE: "clerk"
NEXT_PUBLIC_API_URL: "http://localhost:8000"
NEXT_PUBLIC_AUTH_MODE: "local"
NEXT_TELEMETRY_DISABLED: "1"
# Clerk testing tokens (official @clerk/testing Cypress integration)
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
# Also set for the app itself.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
run: |
cd frontend
npm run e2e -- --browser chrome
+2 -1
View File
@@ -22,4 +22,5 @@ node_modules/
backend/~/
backend/coverage.*
backend/.coverage
frontend/coverage
frontend/coverage
backend/app/services/openclaw/.device-keys
+3 -2
View File
@@ -11,8 +11,9 @@ This repo welcomes contributions in three broad categories:
## Where to start
- Docs landing page: [Docs landing](./docs/README.md)
- Development workflow: [Development workflow](./docs/03-development.md)
- Testing guide: [Testing guide](./docs/testing/README.md)
- Development workflow: [Development](./docs/development/README.md)
- Testing guide: [Testing](./docs/testing/README.md)
- Release checklist: [Release checklist](./docs/release/README.md)
## Filing issues
+18 -2
View File
@@ -55,10 +55,10 @@ frontend-format-check: frontend-tooling ## Check frontend formatting (prettier)
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}"
.PHONY: lint
lint: backend-lint frontend-lint ## Lint backend + frontend
lint: backend-lint frontend-lint docs-lint ## Lint backend + frontend + docs
.PHONY: backend-lint
backend-lint: ## Lint backend (flake8)
backend-lint: backend-format-check backend-typecheck ## Lint backend (isort/black checks + flake8 + mypy)
cd $(BACKEND_DIR) && uv run flake8 --config .flake8
.PHONY: frontend-lint
@@ -142,6 +142,22 @@ frontend-build: frontend-tooling ## Build frontend (next build)
api-gen: frontend-tooling ## Regenerate TS API client (requires backend running at 127.0.0.1:8000)
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run api:gen
.PHONY: docker-up
docker-up: ## Start full Docker stack with image rebuild
docker compose -f compose.yml --env-file .env up -d --build
.PHONY: docker-watch
docker-watch: ## Start stack in watch mode (auto rebuild frontend on UI changes)
docker compose -f compose.yml --env-file .env up --build --watch
.PHONY: docker-watch-only
docker-watch-only: ## Attach file watch to an already-running stack
docker compose -f compose.yml --env-file .env watch
.PHONY: docker-down
docker-down: ## Stop full Docker stack
docker compose -f compose.yml --env-file .env down
.PHONY: rq-worker
rq-worker: ## Run background queue worker loop
cd $(BACKEND_DIR) && uv run python ../scripts/rq worker
+36 -2
View File
@@ -1,6 +1,6 @@
# OpenClaw Mission Control
[![CI](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml/badge.svg)](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml)
[![CI](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml/badge.svg)](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml) ![Static Badge](https://img.shields.io/badge/Join-Slack-active?style=flat&color=blue&link=https%3A%2F%2Fjoin.slack.com%2Ft%2Foc-mission-control%2Fshared_invite%2Fzt-3qpcm57xh-AI9C~smc3MDBVzEhvwf7gg)
OpenClaw Mission Control is the centralized operations and governance platform for running OpenClaw across teams and organizations, with unified visibility, approval controls, and gateway-aware orchestration.
It gives operators a single interface for work orchestration, agent and gateway management, approval-driven governance, and API-backed automation.
@@ -57,6 +57,8 @@ If you haven't cloned the repo yet, you can run the installer in one line:
curl -fsSL https://raw.githubusercontent.com/abhi1693/openclaw-mission-control/master/install.sh | bash
```
This clones the repository into `./openclaw-mission-control` if no local checkout is found in your current directory.
If you already cloned the repo:
```bash
@@ -76,6 +78,7 @@ Installer support matrix: [`docs/installer-support.md`](./docs/installer-support
### Prerequisites
- **Supported platforms**: Linux and macOS. On macOS, Docker mode requires [Docker Desktop](https://www.docker.com/products/docker-desktop/); local mode requires [Homebrew](https://brew.sh) and Node.js 22+.
- Docker Engine
- Docker Compose v2 (`docker compose`)
@@ -88,7 +91,8 @@ cp .env.example .env
Before startup:
- Set `LOCAL_AUTH_TOKEN` to a non-placeholder value (minimum 50 characters) when `AUTH_MODE=local`.
- Ensure `NEXT_PUBLIC_API_URL` is reachable from your browser.
- `NEXT_PUBLIC_API_URL=auto` (default) resolves to `http(s)://<current-host>:8000`.
- Set an explicit URL when your API is behind a reverse proxy or non-default port.
### 2. Start Mission Control
@@ -96,6 +100,36 @@ Before startup:
docker compose -f compose.yml --env-file .env up -d --build
```
If you are iterating on the UI in Docker and want automatic frontend rebuilds on
source changes, run:
```bash
docker compose -f compose.yml --env-file .env up --build --watch
```
Notes:
- Compose Watch requires Docker Compose **2.22.0+**.
- You can also run watch separately after startup:
```bash
docker compose -f compose.yml --env-file .env up -d --build
docker compose -f compose.yml --env-file .env watch
```
After pulling new changes, rebuild and recreate all services:
```bash
docker compose -f compose.yml --env-file .env up -d --build --force-recreate
```
For a fully clean rebuild (no cached build layers):
```bash
docker compose -f compose.yml --env-file .env build --no-cache --pull
docker compose -f compose.yml --env-file .env up -d --force-recreate
```
### 3. Open the application
- Mission Control UI: http://localhost:3000
+7
View File
@@ -5,8 +5,15 @@ LOG_USE_UTC=false
REQUEST_LOG_SLOW_MS=1000
REQUEST_LOG_INCLUDE_HEALTH=false
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control
# For remote access, set this to your UI origin (e.g. http://<server-ip>:3000 or https://mc.example.com).
CORS_ORIGINS=http://localhost:3000
# REQUIRED for gateway provisioning/agent heartbeats. Must be reachable by gateway runtime.
BASE_URL=
# Security response headers (blank values disable each header).
SECURITY_HEADER_X_CONTENT_TYPE_OPTIONS=
SECURITY_HEADER_X_FRAME_OPTIONS=
SECURITY_HEADER_REFERRER_POLICY=
SECURITY_HEADER_PERMISSIONS_POLICY=
# Auth mode: clerk or local.
AUTH_MODE=local
+38
View File
@@ -0,0 +1,38 @@
# Commit-safe backend test environment.
# Usage:
# cd backend
# uv run --env-file .env.test uvicorn app.main:app --reload --port 8000
ENVIRONMENT=dev
LOG_LEVEL=INFO
LOG_FORMAT=text
LOG_USE_UTC=false
REQUEST_LOG_SLOW_MS=1000
REQUEST_LOG_INCLUDE_HEALTH=false
# Local backend -> local Postgres (adjust host/port if needed)
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control_test
CORS_ORIGINS=http://localhost:3000
BASE_URL=http://localhost:8000
# Auth mode: local for test/dev
AUTH_MODE=local
# Must be non-placeholder and >= 50 chars
LOCAL_AUTH_TOKEN=test-local-token-0123456789-0123456789-0123456789x
# Clerk settings kept empty in local auth mode
CLERK_SECRET_KEY=
CLERK_API_URL=https://api.clerk.com
CLERK_VERIFY_IAT=true
CLERK_LEEWAY=10.0
# Database
DB_AUTO_MIGRATE=true
# Queue / dispatch
RQ_REDIS_URL=redis://localhost:6379/0
RQ_QUEUE_NAME=default
RQ_DISPATCH_THROTTLE_SECONDS=15.0
RQ_DISPATCH_MAX_RETRIES=3
GATEWAY_MIN_VERSION=2026.02.9
+5 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
# System deps (keep minimal)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& apt-get install -y --no-install-recommends curl ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
# Install uv (https://github.com/astral-sh/uv)
@@ -42,6 +42,10 @@ COPY backend/app ./app
# In-repo these live at `backend/templates/`; runtime path is `/app/templates`.
COPY backend/templates ./templates
# Copy worker scripts.
# In-repo these live at `scripts/`; runtime path is `/app/scripts`.
COPY scripts ./scripts
# Default API port
EXPOSE 8000
+6 -3
View File
@@ -57,7 +57,7 @@ A starter file exists at `backend/.env.example`.
`postgresql+psycopg://postgres:postgres@localhost:5432/mission_control`
- `CORS_ORIGINS` (comma-separated)
- Example: `http://localhost:3000`
- `BASE_URL` (optional)
- `BASE_URL` (required for gateway provisioning/agent heartbeat templates; no fallback)
### Database lifecycle
@@ -101,17 +101,20 @@ Notes:
From repo root (recommended):
```bash
make backend-test
make backend-lint
make backend-typecheck
make backend-test
make backend-coverage
```
`make backend-lint` runs backend format checks (`isort`, `black`), lint (`flake8`), and typecheck (`mypy`) in one command.
Or from `backend/`:
```bash
cd backend
uv run pytest
uv run isort . --check-only --diff
uv run black . --check --diff
uv run flake8 --config .flake8
uv run mypy
```
+114 -8
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, desc, func
from sqlalchemy import and_, asc, desc, func, or_
from sqlmodel import col, select
from sse_starlette.sse import EventSourceResponse
@@ -78,6 +78,46 @@ def _agent_role(agent: Agent | None) -> str | None:
return None
def _build_activity_route(
*,
event: ActivityEvent,
board_id: UUID | None,
) -> tuple[str, dict[str, str]]:
if board_id is not None:
board_id_str = str(board_id)
board_params = {"boardId": board_id_str}
if event.event_type == "task.comment" and event.task_id is not None:
return (
"board",
{
**board_params,
"taskId": str(event.task_id),
"commentId": str(event.id),
},
)
if event.event_type.startswith("approval."):
return ("board.approvals", board_params)
if event.event_type.startswith("board."):
return ("board", {**board_params, "panel": "chat"})
if event.task_id is not None:
return ("board", {**board_params, "taskId": str(event.task_id)})
return ("board", board_params)
fallback_params = {
"eventId": str(event.id),
"eventType": event.event_type,
"createdAt": event.created_at.isoformat(),
}
if event.task_id is not None:
fallback_params["taskId"] = str(event.task_id)
return ("activity", fallback_params)
def _feed_item(
event: ActivityEvent,
task: Task,
@@ -141,6 +181,46 @@ def _coerce_task_comment_rows(
return rows
def _coerce_activity_rows(
items: Sequence[Any],
) -> list[tuple[ActivityEvent, UUID | None, UUID | None]]:
rows: list[tuple[ActivityEvent, UUID | None, UUID | None]] = []
for item in items:
first: Any
second: Any
third: Any
if isinstance(item, tuple):
if len(item) != 3:
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg)
first, second, third = item
else:
try:
row_len = len(item)
first = item[0]
second = item[1]
third = item[2]
except (IndexError, KeyError, TypeError):
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg) from None
if row_len != 3:
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg)
if not isinstance(first, ActivityEvent):
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg)
if second is not None and not isinstance(second, UUID):
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg)
if third is not None and not isinstance(third, UUID):
msg = "Expected (ActivityEvent, event_board_id, task_board_id) rows"
raise TypeError(msg)
rows.append((first, second, third))
return rows
async def _fetch_task_comment_events(
session: AsyncSession,
since: datetime,
@@ -168,9 +248,13 @@ async def list_activity(
actor: ActorContext = ACTOR_DEP,
) -> LimitOffsetPage[ActivityEventRead]:
"""List activity events visible to the calling actor."""
statement = select(ActivityEvent)
statement: Any = select(
ActivityEvent,
col(ActivityEvent.board_id).label("event_board_id"),
col(Task.board_id).label("task_board_id"),
).outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id))
if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
statement = statement.where(col(ActivityEvent.agent_id) == actor.agent.id)
elif actor.actor_type == "user" and actor.user:
member = await get_active_membership(session, actor.user)
if member is None:
@@ -179,12 +263,34 @@ async def list_activity(
if not board_ids:
statement = statement.where(col(ActivityEvent.id).is_(None))
else:
statement = statement.join(
Task,
col(ActivityEvent.task_id) == col(Task.id),
).where(col(Task.board_id).in_(board_ids))
statement = statement.where(
or_(
col(ActivityEvent.board_id).in_(board_ids),
and_(
col(ActivityEvent.board_id).is_(None),
col(Task.board_id).in_(board_ids),
),
),
)
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
return await paginate(session, statement)
def _transform(items: Sequence[Any]) -> Sequence[Any]:
rows = _coerce_activity_rows(items)
events: list[ActivityEventRead] = []
for event, event_board_id, task_board_id in rows:
payload = ActivityEventRead.model_validate(event, from_attributes=True)
resolved_board_id = event_board_id or task_board_id
payload.board_id = resolved_board_id
route_name, route_params = _build_activity_route(
event=event,
board_id=resolved_board_id,
)
payload.route_name = route_name
payload.route_params = route_params
events.append(payload)
return events
return await paginate(session, statement, transformer=_transform)
@router.get(
+137 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from enum import Enum
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
@@ -20,6 +21,7 @@ from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.boards import Board
from app.models.tags import Tag
from app.models.task_dependencies import TaskDependency
@@ -33,6 +35,7 @@ from app.schemas.agents import (
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
from app.schemas.board_webhooks import BoardWebhookPayloadRead
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse
from app.schemas.errors import LLMErrorResponse
@@ -167,6 +170,53 @@ def _agent_board_openapi_hints(
}
def _truncate_preview(raw: str, max_chars: int) -> str:
if len(raw) <= max_chars:
return raw
if max_chars <= 3:
return raw[:max_chars]
return f"{raw[: max_chars - 3]}..."
def _payload_preview_with_limit(
value: dict[str, object] | list[object] | str | int | float | bool | None,
*,
max_chars: int,
) -> tuple[str, bool]:
if isinstance(value, str):
return _truncate_preview(value, max_chars), len(value) > max_chars
try:
# Stream JSON chunks so we can stop once we know truncation is required.
encoder = json.JSONEncoder(ensure_ascii=True)
parts: list[str] = []
current_len = 0
truncated = False
for chunk in encoder.iterencode(value):
remaining = (max_chars + 1) - current_len
if remaining <= 0:
truncated = True
break
if len(chunk) <= remaining:
parts.append(chunk)
current_len += len(chunk)
continue
parts.append(chunk[:remaining])
current_len += remaining
truncated = True
break
raw = "".join(parts)
except TypeError:
raw = str(value)
return _truncate_preview(raw, max_chars), len(raw) > max_chars
if len(raw) > max_chars:
truncated = True
if not truncated:
return raw, False
return _truncate_preview(raw, max_chars), True
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
allowed = not (agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id)
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
@@ -572,6 +622,73 @@ async def list_tags(
]
@router.get(
"/boards/{board_id}/webhooks/{webhook_id}/payloads/{payload_id}",
response_model=BoardWebhookPayloadRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_webhook_payload_read",
when_to_use=[
"Agent needs to inspect a previously captured webhook payload for this board.",
"Agent is reconciling missed webhook events or deduping inbound processing.",
],
routing_examples=[
{
"input": {
"intent": "inspect stored webhook payload by id",
"required_privilege": "any_agent",
},
"decision": "agent_board_webhook_payload_read",
},
{
"input": {
"intent": "list tasks for planning",
"required_privilege": "any_agent",
},
"decision": "agent_board_task_discovery",
},
],
),
)
async def get_webhook_payload(
webhook_id: UUID,
payload_id: UUID,
max_chars: int | None = Query(default=None, ge=1, le=1_000_000),
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> BoardWebhookPayloadRead:
"""Fetch a stored webhook payload (agent-accessible, read-only).
This enables board-scoped agents to backfill dropped webhook events and enforce
idempotency by inspecting previously received payloads.
If `max_chars` is provided and the serialized payload exceeds the limit,
the response payload is returned as a truncated string preview.
"""
_guard_board_access(agent_ctx, board)
payload = (
await session.exec(
select(BoardWebhookPayload)
.where(col(BoardWebhookPayload.id) == payload_id)
.where(col(BoardWebhookPayload.board_id) == board.id)
.where(col(BoardWebhookPayload.webhook_id) == webhook_id),
)
).first()
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
response = BoardWebhookPayloadRead.model_validate(payload, from_attributes=True)
if max_chars is not None and response.payload is not None:
preview, was_truncated = _payload_preview_with_limit(response.payload, max_chars=max_chars)
if was_truncated:
response.payload = preview
return response
@router.post(
"/boards/{board_id}/tasks",
response_model=TaskRead,
@@ -742,6 +859,7 @@ async def create_task(
task_id=task.id,
message=f"Task created by lead: {task.title}.",
agent_id=agent_ctx.agent.id,
board_id=task.board_id,
)
await session.commit()
if task.assigned_agent_id:
@@ -1429,11 +1547,25 @@ async def get_agent_soul(
target_agent_id=agent_id,
)
coordination = GatewayCoordinationService(session)
return await coordination.get_agent_soul(
board=board,
target_agent_id=agent_id,
correlation_id=f"soul.read:{board.id}:{agent_id}",
)
try:
return await coordination.get_agent_soul(
board=board,
target_agent_id=agent_id,
correlation_id=f"soul.read:{board.id}:{agent_id}",
)
except HTTPException as exc:
# Keep explicit auth/not-found responses, but avoid relaying internal 5xx details.
if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR:
raise HTTPException(
status_code=exc.status_code,
detail="Gateway SOUL read failed",
) from exc
raise
except Exception as exc: # pragma: no cover - defensive API boundary guard
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Gateway SOUL read failed",
) from exc
@router.put(
+2
View File
@@ -266,6 +266,7 @@ async def _notify_lead_on_approval_resolution(
message=f"Lead agent notified for {approval.status} approval {approval.id}.",
agent_id=lead.id,
task_id=approval.task_id,
board_id=approval.board_id,
)
else:
record_activity(
@@ -274,6 +275,7 @@ async def _notify_lead_on_approval_resolution(
message=f"Lead notify failed for approval {approval.id}: {error}",
agent_id=lead.id,
task_id=approval.task_id,
board_id=approval.board_id,
)
await session.commit()
+1 -1
View File
@@ -331,7 +331,7 @@ async def _notify_group_memory_targets(
if len(snippet) > MAX_SNIPPET_LENGTH:
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
base_url = settings.base_url or "http://localhost:8000"
base_url = settings.base_url
context = _NotifyGroupContext(
session=session,
+1 -1
View File
@@ -191,7 +191,7 @@ async def _notify_chat_targets(
snippet = memory.content.strip()
if len(snippet) > MAX_SNIPPET_LENGTH:
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
base_url = settings.base_url or "http://localhost:8000"
base_url = settings.base_url
for agent in targets.values():
if not agent.openclaw_session_id:
continue
+1 -1
View File
@@ -229,7 +229,7 @@ async def start_onboarding(
return onboarding
dispatcher = BoardOnboardingMessagingService(session)
base_url = settings.base_url or "http://localhost:8000"
base_url = settings.base_url
prompt = (
"BOARD ONBOARDING REQUEST\n\n"
f"Board Name: {board.name}\n"
+114
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Literal, cast
from uuid import UUID
@@ -63,6 +65,43 @@ _ERR_GATEWAY_MAIN_AGENT_REQUIRED = (
)
def _format_board_field_value(value: object) -> str:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
if isinstance(value, dict):
return json.dumps(value, sort_keys=True, default=str)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
return str(value)
def _board_update_message(
*,
board: Board,
changed_fields: dict[str, tuple[object, object]],
) -> str:
lines = [
"BOARD UPDATED",
f"Board: {board.name}",
f"Board ID: {board.id}",
"",
"Changed fields:",
]
for field_name in sorted(changed_fields):
previous, current = changed_fields[field_name]
lines.append(
f"- {field_name}: {_format_board_field_value(previous)}"
f" -> {_format_board_field_value(current)}"
)
lines.append("")
lines.append("Take action: review the board changes and adjust plan/assignments as needed.")
return "\n".join(lines)
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
main_agent = (
await Agent.objects.filter_by(gateway_id=gateway.id)
@@ -306,6 +345,7 @@ async def _notify_agents_on_board_group_change(
f"{recipient_board.name} related to {board.name} and {group.name}."
),
agent_id=agent.id,
board_id=recipient_board.id,
)
else:
failed += 1
@@ -317,6 +357,7 @@ async def _notify_agents_on_board_group_change(
f"{recipient_board.name}: {error}"
),
agent_id=agent.id,
board_id=recipient_board.id,
)
if notified or failed:
@@ -366,6 +407,55 @@ async def _notify_agents_on_board_group_removal(
)
async def _notify_lead_on_board_update(
*,
session: AsyncSession,
board: Board,
changed_fields: dict[str, tuple[object, object]],
) -> None:
if not changed_fields:
return
lead = (
await Agent.objects.filter_by(board_id=board.id)
.filter(col(Agent.is_board_lead).is_(True))
.first(session)
)
if lead is None or not lead.openclaw_session_id:
return
dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None:
return
message = _board_update_message(
board=board,
changed_fields=changed_fields,
)
error = await dispatch.try_send_agent_message(
session_key=lead.openclaw_session_id,
config=config,
agent_name=lead.name,
message=message,
deliver=False,
)
if error is None:
record_activity(
session,
event_type="board.lead_notified",
message=f"Lead agent notified for board update: {board.name}.",
agent_id=lead.id,
board_id=board.id,
)
else:
record_activity(
session,
event_type="board.lead_notify_failed",
message=f"Lead board update notify failed for {board.name}: {error}",
agent_id=lead.id,
board_id=board.id,
)
await session.commit()
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
async def list_boards(
gateway_id: UUID | None = GATEWAY_ID_QUERY,
@@ -450,8 +540,19 @@ async def update_board(
board: Board = BOARD_USER_WRITE_DEP,
) -> Board:
"""Update mutable board properties."""
requested_updates = payload.model_dump(exclude_unset=True)
previous_values = {
field_name: getattr(board, field_name)
for field_name in requested_updates
if hasattr(board, field_name)
}
previous_group_id = board.board_group_id
updated = await _apply_board_update(payload=payload, session=session, board=board)
changed_fields = {
field_name: (previous_value, getattr(updated, field_name))
for field_name, previous_value in previous_values.items()
if previous_value != getattr(updated, field_name)
}
new_group_id = updated.board_group_id
if previous_group_id is not None and previous_group_id != new_group_id:
previous_group = await crud.get_by_id(session, BoardGroup, previous_group_id)
@@ -483,6 +584,19 @@ async def update_board(
updated.id,
new_group_id,
)
if changed_fields:
try:
await _notify_lead_on_board_update(
session=session,
board=updated,
changed_fields=changed_fields,
)
except (OpenClawGatewayError, OSError, RuntimeError, ValueError):
logger.exception(
"board.update.notify_lead_unexpected board_id=%s changed_fields=%s",
updated.id,
sorted(changed_fields),
)
return updated
+4
View File
@@ -37,11 +37,15 @@ def _query_to_resolve_input(
board_id: str | None = Query(default=None),
gateway_url: str | None = Query(default=None),
gateway_token: str | None = Query(default=None),
gateway_disable_device_pairing: bool | None = Query(default=None),
gateway_allow_insecure_tls: bool | None = Query(default=None),
) -> GatewayResolveQuery:
return GatewaySessionService.to_resolve_query(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
gateway_disable_device_pairing=gateway_disable_device_pairing,
gateway_allow_insecure_tls=gateway_allow_insecure_tls,
)
+24 -3
View File
@@ -94,7 +94,12 @@ async def create_gateway(
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session)
await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token)
await service.assert_gateway_runtime_compatible(
url=payload.url,
token=payload.token,
allow_insecure_tls=payload.allow_insecure_tls,
disable_device_pairing=payload.disable_device_pairing,
)
data = payload.model_dump()
gateway_id = uuid4()
data["id"] = gateway_id
@@ -134,12 +139,28 @@ async def update_gateway(
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
if "url" in updates or "token" in updates:
if (
"url" in updates
or "token" in updates
or "allow_insecure_tls" in updates
or "disable_device_pairing" in updates
):
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
next_allow_insecure_tls = bool(
updates.get("allow_insecure_tls", gateway.allow_insecure_tls),
)
next_disable_device_pairing = bool(
updates.get("disable_device_pairing", gateway.disable_device_pairing),
)
if next_url:
await service.assert_gateway_runtime_compatible(url=next_url, token=next_token)
await service.assert_gateway_runtime_compatible(
url=next_url,
token=next_token,
allow_insecure_tls=next_allow_insecure_tls,
disable_device_pairing=next_disable_device_pairing,
)
await crud.patch(session, gateway, updates)
await service.ensure_main_agent(gateway, auth, action="update")
return gateway
+79 -12
View File
@@ -18,12 +18,15 @@ from app.core.time import utcnow
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.metrics import (
DashboardBucketKey,
DashboardKpis,
DashboardMetrics,
DashboardPendingApproval,
DashboardPendingApprovals,
DashboardRangeKey,
DashboardRangeSeries,
DashboardSeriesPoint,
@@ -169,7 +172,7 @@ async def _query_throughput(
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
statement = (
select(bucket_col, func.count())
.where(col(Task.status) == "review")
.where(col(Task.status) == "done")
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
)
@@ -370,22 +373,79 @@ async def _active_agents(
return int(result)
async def _tasks_in_progress(
async def _task_status_counts(
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> int:
) -> dict[str, int]:
if not board_ids:
return 0
return {
"inbox": 0,
"in_progress": 0,
"review": 0,
"done": 0,
}
statement = (
select(func.count())
.where(col(Task.status) == "in_progress")
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
select(col(Task.status), func.count())
.where(col(Task.board_id).in_(board_ids))
.group_by(col(Task.status))
)
result = (await session.exec(statement)).one()
return int(result)
results = (await session.exec(statement)).all()
counts = {
"inbox": 0,
"in_progress": 0,
"review": 0,
"done": 0,
}
for status_value, total in results:
key = str(status_value)
if key in counts:
counts[key] = int(total or 0)
return counts
async def _pending_approvals_snapshot(
session: AsyncSession,
board_ids: list[UUID],
*,
limit: int = 10,
) -> DashboardPendingApprovals:
if not board_ids:
return DashboardPendingApprovals(total=0, items=[])
total_statement = (
select(func.count(col(Approval.id)))
.where(col(Approval.board_id).in_(board_ids))
.where(col(Approval.status) == "pending")
)
total = int((await session.exec(total_statement)).one() or 0)
if total == 0:
return DashboardPendingApprovals(total=0, items=[])
rows = (
await session.exec(
select(Approval, Board, Task)
.join(Board, col(Board.id) == col(Approval.board_id))
.outerjoin(Task, col(Task.id) == col(Approval.task_id))
.where(col(Approval.board_id).in_(board_ids))
.where(col(Approval.status) == "pending")
.order_by(col(Approval.created_at).desc())
.limit(limit)
)
).all()
items = [
DashboardPendingApproval(
approval_id=approval.id,
board_id=approval.board_id,
board_name=board.name,
action_type=approval.action_type,
confidence=float(approval.confidence),
created_at=approval.created_at,
task_title=task.title if task is not None else None,
)
for approval, board, task in rows
]
return DashboardPendingApprovals(total=total, items=items)
async def _resolve_dashboard_board_ids(
@@ -461,10 +521,16 @@ async def dashboard_metrics(
primary=wip_primary,
comparison=wip_comparison,
)
task_status_counts = await _task_status_counts(session, board_ids)
pending_approvals = await _pending_approvals_snapshot(session, board_ids, limit=10)
kpis = DashboardKpis(
active_agents=await _active_agents(session, primary, board_ids),
tasks_in_progress=await _tasks_in_progress(session, primary, board_ids),
tasks_in_progress=task_status_counts["in_progress"],
inbox_tasks=task_status_counts["inbox"],
in_progress_tasks=task_status_counts["in_progress"],
review_tasks=task_status_counts["review"],
done_tasks=task_status_counts["done"],
error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
median_cycle_time_hours_7d=await _median_cycle_time_for_range(
session,
@@ -481,4 +547,5 @@ async def dashboard_metrics(
cycle_time=cycle_time,
error_rate=error_rate,
wip=wip,
pending_approvals=pending_approvals,
)
+1 -1
View File
@@ -50,7 +50,7 @@ ORG_ADMIN_DEP = Depends(require_org_admin)
GATEWAY_ID_QUERY = Query(...)
ALLOWED_PACK_SOURCE_SCHEMES = {"https"}
GIT_CLONE_TIMEOUT_SECONDS = 30
GIT_CLONE_TIMEOUT_SECONDS = 600
GIT_REV_PARSE_TIMEOUT_SECONDS = 10
BRANCH_NAME_ALLOWED_RE = r"^[A-Za-z0-9._/\-]+$"
SKILLS_INDEX_READ_CHUNK_BYTES = 16 * 1024
+301 -27
View File
@@ -259,6 +259,19 @@ async def _require_review_before_done_when_enabled(
raise _review_required_for_done_error()
async def _require_comment_for_review_when_enabled(
session: AsyncSession,
*,
board_id: UUID,
) -> bool:
requires_comment = (
await session.exec(
select(col(Board.comment_required_for_review)).where(col(Board.id) == board_id),
)
).first()
return bool(requires_comment)
async def _require_no_pending_approval_for_status_change_when_enabled(
session: AsyncSession,
*,
@@ -318,22 +331,41 @@ async def has_valid_recent_comment(
def _parse_since(value: str | None) -> datetime | None:
"""Parse an optional ISO-8601 timestamp into a naive UTC `datetime`.
The API accepts either naive timestamps (treated as UTC) or timezone-aware values.
Returning naive UTC simplifies SQLModel comparisons against stored naive UTC values.
"""
if not value:
return None
normalized = value.strip()
if not normalized:
return None
# Allow common ISO-8601 `Z` suffix (UTC) even though `datetime.fromisoformat` expects `+00:00`.
normalized = normalized.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(UTC).replace(tzinfo=None)
# No tzinfo: interpret as UTC for consistency with other API timestamps.
return parsed
def _coerce_task_items(items: Sequence[object]) -> list[Task]:
"""Validate/convert paginated query results to a concrete `list[Task]`.
SQLModel pagination helpers return `Sequence[object]`; we validate types early so the
rest of the route logic can assume real `Task` instances.
"""
tasks: list[Task] = []
for item in items:
if not isinstance(item, Task):
@@ -346,6 +378,15 @@ def _coerce_task_items(items: Sequence[object]) -> list[Task]:
def _coerce_task_event_rows(
items: Sequence[object],
) -> list[tuple[ActivityEvent, Task | None]]:
"""Normalize DB rows into `(ActivityEvent, Task | None)` tuples.
Depending on the SQLAlchemy/SQLModel execution path, result rows may arrive as:
- real Python tuples, or
- row-like objects supporting `__len__` and `__getitem__`.
This helper centralizes validation so SSE/event-stream logic can assume a stable shape.
"""
rows: list[tuple[ActivityEvent, Task | None]] = []
for item in items:
first: object
@@ -382,6 +423,12 @@ async def _lead_was_mentioned(
task: Task,
lead: Agent,
) -> bool:
"""Return `True` if the lead agent is mentioned in any comment on the task.
This is used to avoid redundant lead pings (especially in auto-created tasks) while still
ensuring escalation happens when explicitly requested.
"""
statement = (
select(ActivityEvent.message)
.where(col(ActivityEvent.task_id) == task.id)
@@ -398,6 +445,8 @@ async def _lead_was_mentioned(
def _lead_created_task(task: Task, lead: Agent) -> bool:
"""Return `True` if `task` was auto-created by the lead agent."""
if not task.auto_created or not task.auto_reason:
return False
return task.auto_reason == f"lead_agent:{lead.id}"
@@ -411,6 +460,13 @@ async def _reconcile_dependents_for_dependency_toggle(
previous_status: str,
actor_agent_id: UUID | None,
) -> None:
"""Apply dependency side-effects when a dependency task toggles done/undone.
The UI models dependencies as a DAG: when a dependency is reopened, dependents that were
previously marked done may need to be reopened or flagged. This helper keeps dependent state
consistent with the dependency graph without duplicating logic across endpoints.
"""
done_toggled = (previous_status == "done") != (dependency_task.status == "done")
if not done_toggled:
return
@@ -455,6 +511,7 @@ async def _reconcile_dependents_for_dependency_toggle(
"Task returned to inbox: dependency reopened " f"({dependency_task.title})."
),
agent_id=actor_agent_id,
board_id=dependent.board_id,
)
else:
record_activity(
@@ -463,6 +520,7 @@ async def _reconcile_dependents_for_dependency_toggle(
task_id=dependent.id,
message=f"Dependency completion changed: {dependency_task.title}.",
agent_id=actor_agent_id,
board_id=dependent.board_id,
)
else:
record_activity(
@@ -471,6 +529,7 @@ async def _reconcile_dependents_for_dependency_toggle(
task_id=dependent.id,
message=f"Dependency completion changed: {dependency_task.title}.",
agent_id=actor_agent_id,
board_id=dependent.board_id,
)
@@ -533,6 +592,75 @@ async def _send_agent_task_message(
)
def _assignment_notification_message(*, board: Board, task: Task, agent: Agent) -> str:
description = _truncate_snippet(task.description or "")
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
if task.status == "review" and agent.is_board_lead:
action = (
"Take action: review the deliverables now. "
"Approve by moving to done or return to inbox with clear feedback."
)
return "TASK READY FOR LEAD REVIEW\n" + "\n".join(details) + f"\n\n{action}"
return (
"TASK ASSIGNED\n"
+ "\n".join(details)
+ ("\n\nTake action: open the task and begin work. " "Post updates as task comments.")
)
def _rework_notification_message(
*,
board: Board,
task: Task,
feedback: str | None,
) -> str:
description = _truncate_snippet(task.description or "")
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
requested_changes = (
_truncate_snippet(feedback)
if feedback and feedback.strip()
else "Lead requested changes. Review latest task comments for exact required updates."
)
return (
"CHANGES REQUESTED\n"
+ "\n".join(details)
+ "\n\nRequested changes:\n"
+ requested_changes
+ "\n\nTake action: address the requested changes, then move the task back to review."
)
async def _latest_task_comment_by_agent(
session: AsyncSession,
*,
task_id: UUID,
agent_id: UUID,
) -> str | None:
statement = (
select(col(ActivityEvent.message))
.where(col(ActivityEvent.task_id) == task_id)
.where(col(ActivityEvent.event_type) == "task.comment")
.where(col(ActivityEvent.agent_id) == agent_id)
.order_by(desc(col(ActivityEvent.created_at)))
.limit(1)
)
return (await session.exec(statement)).first()
async def _notify_agent_on_task_assign(
*,
session: AsyncSession,
@@ -546,20 +674,7 @@ async def _notify_agent_on_task_assign(
config = await dispatch.optional_gateway_config_for_board(board)
if config is None:
return
description = _truncate_snippet(task.description or "")
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
message = (
"TASK ASSIGNED\n"
+ "\n".join(details)
+ ("\n\nTake action: open the task and begin work. " "Post updates as task comments.")
)
message = _assignment_notification_message(board=board, task=task, agent=agent)
error = await _send_agent_task_message(
dispatch=dispatch,
session_key=agent.openclaw_session_id,
@@ -574,6 +689,7 @@ async def _notify_agent_on_task_assign(
message=f"Agent notified for assignment: {agent.name}.",
agent_id=agent.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
else:
@@ -583,6 +699,60 @@ async def _notify_agent_on_task_assign(
message=f"Assignee notify failed: {error}",
agent_id=agent.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
async def _notify_agent_on_task_rework(
*,
session: AsyncSession,
board: Board,
task: Task,
agent: Agent,
lead: Agent,
) -> None:
if not agent.openclaw_session_id:
return
dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None:
return
feedback = await _latest_task_comment_by_agent(
session,
task_id=task.id,
agent_id=lead.id,
)
message = _rework_notification_message(
board=board,
task=task,
feedback=feedback,
)
error = await _send_agent_task_message(
dispatch=dispatch,
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
if error is None:
record_activity(
session,
event_type="task.rework_notified",
message=f"Assignee notified about requested changes: {agent.name}.",
agent_id=agent.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
else:
record_activity(
session,
event_type="task.rework_notify_failed",
message=f"Rework notify failed: {error}",
agent_id=agent.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
@@ -647,6 +817,7 @@ async def _notify_lead_on_task_create(
message=f"Lead agent notified for task: {task.title}.",
agent_id=lead.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
else:
@@ -656,6 +827,7 @@ async def _notify_lead_on_task_create(
message=f"Lead notify failed: {error}",
agent_id=lead.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
@@ -704,6 +876,7 @@ async def _notify_lead_on_task_unassigned(
message=f"Lead notified task returned to inbox: {task.title}.",
agent_id=lead.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
else:
@@ -713,6 +886,7 @@ async def _notify_lead_on_task_unassigned(
message=f"Lead notify failed: {error}",
agent_id=lead.id,
task_id=task.id,
board_id=board.id,
)
await session.commit()
@@ -1137,7 +1311,10 @@ def _task_event_payload(
resolved_custom_field_values_by_task_id = custom_field_values_by_task_id or {}
payload: dict[str, object] = {
"type": event.event_type,
"activity": ActivityEventRead.model_validate(event).model_dump(mode="json"),
"activity": ActivityEventRead.model_validate(event).model_dump(
mode="json",
exclude={"board_id", "route_name", "route_params"},
),
}
if event.event_type == "task.comment":
payload["comment"] = _serialize_comment(event)
@@ -1337,6 +1514,7 @@ async def create_task(
event_type="task.created",
task_id=task.id,
message=f"Task created: {task.title}.",
board_id=board.id,
)
await session.commit()
await _notify_lead_on_task_create(session=session, board=board, task=task)
@@ -1905,7 +2083,42 @@ async def _lead_apply_assignment(
update.task.assigned_agent_id = agent.id
def _lead_apply_status(update: _TaskUpdateInput) -> None:
async def _last_worker_who_moved_task_to_review(
session: AsyncSession,
*,
task_id: UUID,
board_id: UUID,
lead_agent_id: UUID,
) -> UUID | None:
statement = (
select(col(ActivityEvent.agent_id))
.where(col(ActivityEvent.task_id) == task_id)
.where(col(ActivityEvent.event_type) == "task.status_changed")
.where(col(ActivityEvent.message).like("Task moved to review:%"))
.where(col(ActivityEvent.agent_id).is_not(None))
.order_by(desc(col(ActivityEvent.created_at)))
)
candidate_ids = list(await session.exec(statement))
for candidate_id in candidate_ids:
if candidate_id is None or candidate_id == lead_agent_id:
continue
candidate = await Agent.objects.by_id(candidate_id).first(session)
if candidate is None:
continue
if candidate.board_id != board_id or candidate.is_board_lead:
continue
return candidate.id
return None
async def _lead_apply_status(
session: AsyncSession,
*,
update: _TaskUpdateInput,
) -> None:
if update.actor.actor_type != "agent" or update.actor.agent is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
lead_agent = update.actor.agent
if "status" not in update.updates:
return
if update.task.status != "review":
@@ -1926,7 +2139,12 @@ def _lead_apply_status(update: _TaskUpdateInput) -> None:
),
)
if target_status == "inbox":
update.task.assigned_agent_id = None
update.task.assigned_agent_id = await _last_worker_who_moved_task_to_review(
session,
task_id=update.task.id,
board_id=update.board_id,
lead_agent_id=lead_agent.id,
)
update.task.in_progress_at = None
update.task.status = target_status
@@ -1958,6 +2176,21 @@ async def _lead_notify_new_assignee(
else None
)
if board:
if (
update.previous_status == "review"
and update.task.status == "inbox"
and update.actor.actor_type == "agent"
and update.actor.agent
and update.actor.agent.is_board_lead
):
await _notify_agent_on_task_rework(
session=session,
board=board,
task=update.task,
agent=assigned_agent,
lead=update.actor.agent,
)
return
await _notify_agent_on_task_assign(
session=session,
board=board,
@@ -1994,7 +2227,7 @@ async def _apply_lead_task_update(
raise _blocked_task_error(blocked_by)
await _lead_apply_assignment(session, update=update)
_lead_apply_status(update)
await _lead_apply_status(session, update=update)
await _require_no_pending_approval_for_status_change_when_enabled(
session,
board_id=update.board_id,
@@ -2040,6 +2273,7 @@ async def _apply_lead_task_update(
task_id=update.task.id,
message=message,
agent_id=update.actor.agent.id,
board_id=update.board_id,
)
await _reconcile_dependents_for_dependency_toggle(
session,
@@ -2225,6 +2459,7 @@ async def _record_task_comment_from_update(
event_type="task.comment",
message=update.comment,
task_id=update.task.id,
board_id=update.task.board_id,
agent_id=(
update.actor.agent.id
if update.actor.actor_type == "agent" and update.actor.agent
@@ -2252,6 +2487,7 @@ async def _record_task_update_activity(
task_id=update.task.id,
message=message,
agent_id=actor_agent_id,
board_id=update.board_id,
)
await _reconcile_dependents_for_dependency_toggle(
session,
@@ -2263,6 +2499,23 @@ async def _record_task_update_activity(
await session.commit()
async def _assign_review_task_to_lead(
session: AsyncSession,
*,
update: _TaskUpdateInput,
) -> None:
if update.task.status != "review" or update.previous_status == "review":
return
lead = (
await Agent.objects.filter_by(board_id=update.board_id)
.filter(col(Agent.is_board_lead).is_(True))
.first(session)
)
if lead is None:
return
update.task.assigned_agent_id = lead.id
async def _notify_task_update_assignment_changes(
session: AsyncSession,
*,
@@ -2290,12 +2543,6 @@ async def _notify_task_update_assignment_changes(
or update.task.assigned_agent_id == update.previous_assigned
):
return
if (
update.actor.actor_type == "agent"
and update.actor.agent
and update.task.assigned_agent_id == update.actor.agent.id
):
return
assigned_agent = await Agent.objects.by_id(update.task.assigned_agent_id).first(
session,
)
@@ -2306,6 +2553,28 @@ async def _notify_task_update_assignment_changes(
if update.task.board_id
else None
)
if (
update.previous_status == "review"
and update.task.status == "inbox"
and update.actor.actor_type == "agent"
and update.actor.agent
and update.actor.agent.is_board_lead
):
if board:
await _notify_agent_on_task_rework(
session=session,
board=board,
task=update.task,
agent=assigned_agent,
lead=update.actor.agent,
)
return
if (
update.actor.actor_type == "agent"
and update.actor.agent
and update.task.assigned_agent_id == update.actor.agent.id
):
return
if board:
await _notify_agent_on_task_assign(
session=session,
@@ -2346,9 +2615,12 @@ async def _finalize_updated_task(
update.task.updated_at = utcnow()
status_raw = update.updates.get("status")
# Entering review requires either a new comment or a valid recent one to
# ensure reviewers get context on readiness.
if status_raw == "review":
# Entering review can require a new comment or valid recent context when
# the board-level rule is enabled.
if status_raw == "review" and await _require_comment_for_review_when_enabled(
session,
board_id=update.board_id,
):
comment_text = (update.comment or "").strip()
review_comment_author = update.task.assigned_agent_id or update.previous_assigned
review_comment_since = (
@@ -2363,6 +2635,7 @@ async def _finalize_updated_task(
review_comment_since,
):
raise _comment_validation_error()
await _assign_review_task_to_lead(session, update=update)
if update.tag_ids is not None:
normalized = (
@@ -2414,6 +2687,7 @@ async def create_task_comment(
event_type="task.comment",
message=payload.message,
task_id=task.id,
board_id=task.board_id,
agent_id=_comment_actor_id(actor),
)
session.add(event)
+17 -8
View File
@@ -143,11 +143,19 @@ async def get_agent_auth_context_optional(
authorization: str | None = Header(default=None, alias="Authorization"),
session: AsyncSession = SESSION_DEP,
) -> AgentAuthContext | None:
"""Optionally resolve agent auth context from `X-Agent-Token` only."""
"""Optionally resolve agent auth context from `X-Agent-Token` or `Authorization: Bearer`.
Both `X-Agent-Token` and `Authorization: Bearer <token>` are accepted so that
routes depending on this function (e.g. board/task dependency resolvers) behave
consistently with `get_agent_auth_context`, which also accepts both headers.
Previously, `accept_authorization=False` caused 401 on any route that resolved
a board or task via the shared `ACTOR_DEP` chain (e.g. PATCH /tasks/{id},
POST /tasks/{id}/comments) when the caller used `Authorization: Bearer`.
"""
resolved = _resolve_agent_token(
agent_token,
authorization,
accept_authorization=False,
accept_authorization=True,
)
if not resolved:
if agent_token:
@@ -160,11 +168,12 @@ async def get_agent_auth_context_optional(
return None
agent = await _find_agent_for_token(session, resolved)
if agent is None:
logger.warning(
"agent auth optional invalid token path=%s token_prefix=%s",
request.url.path,
resolved[:6],
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
if agent_token:
logger.warning(
"agent auth optional invalid token path=%s token_prefix=%s",
request.url.path,
resolved[:6],
)
return None
await _touch_agent_presence(request, session, agent)
return AgentAuthContext(actor_type="agent", agent=agent)
+34 -14
View File
@@ -66,6 +66,13 @@ class AuthContext:
def _extract_bearer_token(authorization: str | None) -> str | None:
"""Extract the bearer token from an `Authorization` header.
Returns `None` for missing/empty headers or non-bearer schemes.
Note: we do *not* validate the token here; this helper is only responsible for parsing.
"""
if not authorization:
return None
value = authorization.strip()
@@ -92,6 +99,14 @@ def _normalize_email(value: object) -> str | None:
def _extract_claim_email(claims: dict[str, object]) -> str | None:
"""Best-effort extraction of an email address from Clerk/JWT-like claims.
Clerk payloads vary depending on token type and SDK version. We try common flat keys first,
then fall back to an `email_addresses` list (either strings or dict-like entries).
Returns a normalized lowercase email or `None`.
"""
for key in ("email", "email_address", "primary_email_address"):
email = _normalize_email(claims.get(key))
if email:
@@ -119,10 +134,13 @@ def _extract_claim_email(claims: dict[str, object]) -> str | None:
return candidate
if fallback_email is None:
fallback_email = candidate
return fallback_email
def _extract_claim_name(claims: dict[str, object]) -> str | None:
"""Best-effort extraction of a display name from Clerk/JWT-like claims."""
for key in ("name", "full_name"):
text = _non_empty_str(claims.get(key))
if text:
@@ -137,6 +155,17 @@ def _extract_claim_name(claims: dict[str, object]) -> str | None:
def _extract_clerk_profile(profile: ClerkUser | None) -> tuple[str | None, str | None]:
"""Extract `(email, name)` from a Clerk user profile.
The Clerk SDK surface is not perfectly consistent across environments:
- some fields may be absent,
- email addresses may be represented as strings or objects,
- the "primary" email may be identified by id.
This helper implements a defensive, best-effort extraction strategy and returns `(None, None)`
when the profile is unavailable.
"""
if profile is None:
return None, None
@@ -208,7 +237,6 @@ async def _authenticate_clerk_request(request: Request) -> RequestState:
async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | None]:
secret = settings.clerk_secret_key.strip()
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
clerk_user_id_log = clerk_user_id[-6:] if clerk_user_id else ""
@@ -223,28 +251,24 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No
return email, name
except ClerkErrors as exc:
logger.warning(
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=clerk_errors "
"secret_kind=%s error_type=%s",
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=clerk_errors " "error_type=%s",
clerk_user_id_log,
secret_kind,
exc.__class__.__name__,
)
except SDKError as exc:
logger.warning(
"auth.clerk.profile.fetch_failed clerk_user_id=%s status=%s reason=sdk_error "
"server_url=%s secret_kind=%s",
"server_url=%s",
clerk_user_id_log,
exc.status_code,
server_url,
secret_kind,
)
except httpx.TimeoutException as exc:
logger.warning(
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=timeout "
"server_url=%s secret_kind=%s error=%s",
"server_url=%s error=%s",
clerk_user_id_log,
server_url,
secret_kind,
str(exc) or exc.__class__.__name__,
)
except Exception as exc:
@@ -264,7 +288,6 @@ async def delete_clerk_user(clerk_user_id: str) -> None:
return
secret = settings.clerk_secret_key.strip()
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
clerk_user_id_log = clerk_user_id[-6:] if clerk_user_id else ""
@@ -278,10 +301,8 @@ async def delete_clerk_user(clerk_user_id: str) -> None:
logger.info("auth.clerk.user.delete clerk_user_id=%s", clerk_user_id_log)
except ClerkErrors as exc:
logger.warning(
"auth.clerk.user.delete_failed clerk_user_id=%s reason=clerk_errors "
"secret_kind=%s error_type=%s",
"auth.clerk.user.delete_failed clerk_user_id=%s reason=clerk_errors " "error_type=%s",
clerk_user_id_log,
secret_kind,
exc.__class__.__name__,
)
raise HTTPException(
@@ -294,11 +315,10 @@ async def delete_clerk_user(clerk_user_id: str) -> None:
return
logger.warning(
"auth.clerk.user.delete_failed clerk_user_id=%s status=%s reason=sdk_error "
"server_url=%s secret_kind=%s",
"server_url=%s",
clerk_user_id_log,
exc.status_code,
server_url,
secret_kind,
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
+16 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Self
from urllib.parse import urlparse
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -48,7 +49,12 @@ class Settings(BaseSettings):
clerk_leeway: float = 10.0
cors_origins: str = ""
base_url: str = ""
base_url: str
# Security response headers (blank disables header injection)
security_header_x_content_type_options: str = ""
security_header_x_frame_options: str = ""
security_header_referrer_policy: str = ""
security_header_permissions_policy: str = ""
# Database lifecycle
db_auto_migrate: bool = False
@@ -88,6 +94,15 @@ class Settings(BaseSettings):
raise ValueError(
"LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.",
)
base_url = self.base_url.strip()
if not base_url:
raise ValueError("BASE_URL must be set and non-empty.")
parsed_base_url = urlparse(base_url)
if parsed_base_url.scheme not in {"http", "https"} or not parsed_base_url.netloc:
raise ValueError(
"BASE_URL must be an absolute http(s) URL (e.g. http://localhost:8000).",
)
self.base_url = base_url.rstrip("/")
# In dev, default to applying Alembic migrations at startup to avoid
# schema drift (e.g. missing newly-added columns).
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
+81
View File
@@ -0,0 +1,81 @@
"""ASGI middleware for configurable security response headers."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class SecurityHeadersMiddleware:
"""Inject configured security headers into every HTTP response."""
_X_CONTENT_TYPE_OPTIONS = b"x-content-type-options"
_X_FRAME_OPTIONS = b"x-frame-options"
_REFERRER_POLICY = b"referrer-policy"
_PERMISSIONS_POLICY = b"permissions-policy"
def __init__(
self,
app: ASGIApp,
*,
x_content_type_options: str = "",
x_frame_options: str = "",
referrer_policy: str = "",
permissions_policy: str = "",
) -> None:
self._app = app
self._configured_headers = self._build_configured_headers(
x_content_type_options=x_content_type_options,
x_frame_options=x_frame_options,
referrer_policy=referrer_policy,
permissions_policy=permissions_policy,
)
@classmethod
def _build_configured_headers(
cls,
*,
x_content_type_options: str,
x_frame_options: str,
referrer_policy: str,
permissions_policy: str,
) -> tuple[tuple[bytes, bytes, bytes], ...]:
configured: list[tuple[bytes, bytes, bytes]] = []
for header_name, value in (
(cls._X_CONTENT_TYPE_OPTIONS, x_content_type_options),
(cls._X_FRAME_OPTIONS, x_frame_options),
(cls._REFERRER_POLICY, referrer_policy),
(cls._PERMISSIONS_POLICY, permissions_policy),
):
normalized = value.strip()
if not normalized:
continue
configured.append(
(
header_name.lower(),
header_name,
normalized.encode("latin-1"),
)
)
return tuple(configured)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Append configured security headers unless already present."""
if scope["type"] != "http" or not self._configured_headers:
await self._app(scope, receive, send)
return
async def send_with_security_headers(message: Message) -> None:
if message["type"] == "http.response.start":
# Starlette uses `list[tuple[bytes, bytes]]` for raw headers.
headers: list[tuple[bytes, bytes]] = message.setdefault("headers", [])
existing = {key.lower() for key, _ in headers}
for key_lower, key, value in self._configured_headers:
if key_lower not in existing:
headers.append((key, value))
existing.add(key_lower)
await send(message)
await self._app(scope, receive, send_with_security_headers)
+8
View File
@@ -34,6 +34,7 @@ from app.api.users import router as users_router
from app.core.config import settings
from app.core.error_handling import install_error_handling
from app.core.logging import configure_logging, get_logger
from app.core.security_headers import SecurityHeadersMiddleware
from app.db.session import init_db
from app.schemas.health import HealthStatusResponse
@@ -464,6 +465,13 @@ if origins:
else:
logger.info("app.cors.disabled")
app.add_middleware(
SecurityHeadersMiddleware,
x_content_type_options=settings.security_header_x_content_type_options,
x_frame_options=settings.security_header_x_frame_options,
referrer_policy=settings.security_header_referrer_policy,
permissions_policy=settings.security_header_permissions_policy,
)
install_error_handling(app)
+2 -1
View File
@@ -14,7 +14,7 @@ RUNTIME_ANNOTATION_TYPES = (datetime,)
class ActivityEvent(QueryModel, table=True):
"""Discrete activity event tied to tasks and agents."""
"""Discrete activity event tied to board/task/agent context."""
__tablename__ = "activity_events" # pyright: ignore[reportAssignmentType]
@@ -23,4 +23,5 @@ class ActivityEvent(QueryModel, table=True):
message: str | None = None
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
task_id: UUID | None = Field(default=None, foreign_key="tasks.id", index=True)
board_id: UUID | None = Field(default=None, foreign_key="boards.id", index=True)
created_at: datetime = Field(default_factory=utcnow)
+5
View File
@@ -43,6 +43,11 @@ class Agent(QueryModel, table=True):
delete_requested_at: datetime | None = Field(default=None)
delete_confirm_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime | None = Field(default=None)
lifecycle_generation: int = Field(default=0)
wake_attempts: int = Field(default=0)
last_wake_sent_at: datetime | None = Field(default=None)
checkin_deadline_at: datetime | None = Field(default=None)
last_provision_error: str | None = Field(default=None, sa_column=Column(Text))
is_board_lead: bool = Field(default=False, index=True)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
+1
View File
@@ -41,6 +41,7 @@ class Board(TenantScoped, table=True):
goal_source: str | None = None
require_approval_for_done: bool = Field(default=True)
require_review_before_done: bool = Field(default=False)
comment_required_for_review: bool = Field(default=False)
block_status_changes_with_pending_approval: bool = Field(default=False)
only_lead_can_change_status: bool = Field(default=False)
max_agents: int = Field(default=1)
+2
View File
@@ -23,6 +23,8 @@ class Gateway(QueryModel, table=True):
name: str
url: str
token: str | None = Field(default=None)
disable_device_pairing: bool = Field(default=False)
workspace_root: str
allow_insecure_tls: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
+3
View File
@@ -18,6 +18,9 @@ class ActivityEventRead(SQLModel):
message: str | None
agent_id: UUID | None
task_id: UUID | None
board_id: UUID | None = None
route_name: str | None = None
route_params: dict[str, str] | None = None
created_at: datetime
+2
View File
@@ -31,6 +31,7 @@ class BoardBase(SQLModel):
goal_source: str | None = None
require_approval_for_done: bool = True
require_review_before_done: bool = False
comment_required_for_review: bool = False
block_status_changes_with_pending_approval: bool = False
only_lead_can_change_status: bool = False
max_agents: int = Field(default=1, ge=0)
@@ -75,6 +76,7 @@ class BoardUpdate(SQLModel):
goal_source: str | None = None
require_approval_for_done: bool | None = None
require_review_before_done: bool | None = None
comment_required_for_review: bool | None = None
block_status_changes_with_pending_approval: bool | None = None
only_lead_can_change_status: bool | None = None
max_agents: int | None = Field(default=None, ge=0)
+1
View File
@@ -55,6 +55,7 @@ class BlockedTaskDetail(SQLModel):
"""Error detail payload listing blocking dependency task identifiers."""
message: str
code: str | None = None
blocked_by_task_ids: list[str] = Field(default_factory=list)
+2
View File
@@ -21,6 +21,8 @@ class GatewayResolveQuery(SQLModel):
board_id: str | None = None
gateway_url: str | None = None
gateway_token: str | None = None
gateway_disable_device_pairing: bool | None = None
gateway_allow_insecure_tls: bool | None = None
class GatewaysStatusResponse(SQLModel):
+4
View File
@@ -17,6 +17,8 @@ class GatewayBase(SQLModel):
name: str
url: str
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
class GatewayCreate(GatewayBase):
@@ -43,6 +45,8 @@ class GatewayUpdate(SQLModel):
url: str | None = None
token: str | None = None
workspace_root: str | None = None
allow_insecure_tls: bool | None = None
disable_device_pairing: bool | None = None
@field_validator("token", mode="before")
@classmethod
+26 -1
View File
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
DashboardRangeKey = Literal["24h", "3d", "7d", "14d", "1m", "3m", "6m", "1y"]
DashboardBucketKey = Literal["hour", "day", "week", "month"]
@@ -64,10 +65,33 @@ class DashboardKpis(SQLModel):
active_agents: int
tasks_in_progress: int
inbox_tasks: int
in_progress_tasks: int
review_tasks: int
done_tasks: int
error_rate_pct: float
median_cycle_time_hours_7d: float | None
class DashboardPendingApproval(SQLModel):
"""Single pending approval item for cross-board dashboard listing."""
approval_id: UUID
board_id: UUID
board_name: str
action_type: str
confidence: float
created_at: datetime
task_title: str | None = None
class DashboardPendingApprovals(SQLModel):
"""Pending approval snapshot used on the dashboard."""
total: int
items: list[DashboardPendingApproval]
class DashboardMetrics(SQLModel):
"""Complete dashboard metrics response payload."""
@@ -78,3 +102,4 @@ class DashboardMetrics(SQLModel):
cycle_time: DashboardSeriesSet
error_rate: DashboardSeriesSet
wip: DashboardWipSeriesSet
pending_approvals: DashboardPendingApprovals
+12 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import re
from datetime import date, datetime
from functools import lru_cache
from typing import Literal, Self
from urllib.parse import urlparse
from uuid import UUID
@@ -297,6 +298,12 @@ def _parse_iso_datetime(value: str) -> datetime:
return datetime.fromisoformat(normalized)
@lru_cache(maxsize=256)
def _compiled_validation_regex(pattern: str) -> re.Pattern[str]:
"""Compile and cache validation regex patterns for value checks."""
return re.compile(pattern)
def validate_custom_field_value(
*,
field_type: TaskCustomFieldType,
@@ -346,7 +353,11 @@ def validate_custom_field_value(
if validation_regex is not None and field_type in STRING_FIELD_TYPES:
if not isinstance(value, str):
raise ValueError("must be a string for regex validation")
if re.fullmatch(validation_regex, value) is None:
try:
pattern = _compiled_validation_regex(validation_regex)
except re.error as exc:
raise ValueError(f"validation_regex is invalid: {exc}") from exc
if pattern.fullmatch(value) is None:
raise ValueError("does not match validation_regex")
+2
View File
@@ -19,6 +19,7 @@ def record_activity(
message: str,
agent_id: UUID | None = None,
task_id: UUID | None = None,
board_id: UUID | None = None,
) -> ActivityEvent:
"""Create and attach an activity event row to the current DB session."""
event = ActivityEvent(
@@ -26,6 +27,7 @@ def record_activity(
message=message,
agent_id=agent_id,
task_id=task_id,
board_id=board_id,
)
session.add(event)
return event
+6
View File
@@ -91,6 +91,12 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
col(TaskCustomFieldValue.task_id).in_(task_ids),
commit=False,
)
await crud.delete_where(
session,
ActivityEvent,
col(ActivityEvent.board_id) == board.id,
commit=False,
)
# Keep teardown ordered around FK/reference chains so dependent rows are gone
# before deleting their parent task/agent/board records.
await crud.delete_where(
+42 -60
View File
@@ -21,23 +21,18 @@ from app.models.gateways import Gateway
from app.models.tasks import Task
from app.schemas.gateways import GatewayTemplatesSyncResult
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
from app.services.openclaw.db_agent_state import (
mark_provision_complete,
mark_provision_requested,
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.error_messages import normalize_gateway_error_message
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator
from app.services.openclaw.provisioning_db import (
GatewayTemplateSyncOptions,
OpenClawProvisioningService,
)
from app.services.openclaw.session_service import GatewayTemplateSyncQuery
from app.services.openclaw.shared import GatewayAgentIdentity
from app.services.organizations import get_org_owner_user
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -167,7 +162,12 @@ class GatewayAdminLifecycleService(OpenClawDBService):
async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool:
if not gateway.url:
return False
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
target_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
try:
await openclaw_call("agents.files.list", {"agentId": target_id}, config=config)
@@ -178,15 +178,28 @@ class GatewayAdminLifecycleService(OpenClawDBService):
return True
return True
async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None:
async def assert_gateway_runtime_compatible(
self,
*,
url: str,
token: str | None,
allow_insecure_tls: bool = False,
disable_device_pairing: bool = False,
) -> None:
"""Validate that a gateway runtime meets minimum supported version."""
config = GatewayClientConfig(url=url, token=token)
config = GatewayClientConfig(
url=url,
token=token,
allow_insecure_tls=allow_insecure_tls,
disable_device_pairing=disable_device_pairing,
)
try:
result = await check_gateway_runtime_compatibility(config)
result = await check_gateway_version_compatibility(config)
except OpenClawGatewayError as exc:
detail = normalize_gateway_error_message(str(exc))
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway compatibility check failed: {exc}",
detail=f"Gateway compatibility check failed: {detail}",
) from exc
if not result.compatible:
raise HTTPException(
@@ -203,69 +216,38 @@ class GatewayAdminLifecycleService(OpenClawDBService):
action: str,
notify: bool,
) -> Agent:
template_user = user or await get_org_owner_user(
self.session,
organization_id=gateway.organization_id,
)
if template_user is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Organization owner not found (required for gateway agent USER.md rendering).",
)
raw_token = mint_agent_token(agent)
mark_provision_requested(
agent,
action=action,
status="updating" if action == "update" else "provisioning",
)
await self.add_commit_refresh(agent)
if not gateway.url:
return agent
orchestrator = AgentLifecycleOrchestrator(self.session)
try:
await OpenClawGatewayProvisioner().apply_agent_lifecycle(
agent=agent,
provisioned = await orchestrator.run_lifecycle(
gateway=gateway,
agent_id=agent.id,
board=None,
auth_token=raw_token,
user=template_user,
user=user,
action=action,
auth_token=None,
force_bootstrap=False,
reset_session=False,
wake=notify,
deliver_wakeup=True,
wakeup_verb=None,
clear_confirm_token=False,
raise_gateway_errors=True,
)
except OpenClawGatewayError as exc:
except HTTPException:
self.logger.error(
"gateway.main_agent.provision_failed_gateway gateway_id=%s agent_id=%s error=%s",
"gateway.main_agent.provision_failed gateway_id=%s agent_id=%s action=%s",
gateway.id,
agent.id,
str(exc),
action,
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway {action} failed: {exc}",
) from exc
except (OSError, RuntimeError, ValueError) as exc:
self.logger.error(
"gateway.main_agent.provision_failed gateway_id=%s agent_id=%s error=%s",
gateway.id,
agent.id,
str(exc),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unexpected error {action}ing gateway provisioning.",
) from exc
mark_provision_complete(agent, status="online")
await self.add_commit_refresh(agent)
raise
self.logger.info(
"gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s",
gateway.id,
agent.id,
provisioned.id,
action,
)
return agent
return provisioned
async def ensure_main_agent(
self,
@@ -18,6 +18,11 @@ DEFAULT_HEARTBEAT_CONFIG: dict[str, Any] = {
}
OFFLINE_AFTER = timedelta(minutes=10)
# Provisioning convergence policy:
# - require first heartbeat/check-in within 30s of wake
# - allow up to 3 wake attempts before giving up
CHECKIN_DEADLINE_AFTER_WAKE = timedelta(seconds=30)
MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN = 3
AGENT_SESSION_PREFIX = "agent"
DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY: dict[str, bool] = {
@@ -93,7 +93,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
reply_tags: list[str] | None,
reply_source: str | None,
) -> str:
base_url = settings.base_url or "http://localhost:8000"
base_url = settings.base_url
header = "GATEWAY MAIN QUESTION" if kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = correlation_id.strip() if correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
@@ -204,6 +204,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="agent.nudge.failed",
message=f"Nudge failed for {target.name}: {exc}",
agent_id=actor_agent.id,
board_id=board.id,
)
await self.session.commit()
self.logger.error(
@@ -233,6 +234,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="agent.nudge.sent",
message=f"Nudge sent to {target.name}.",
agent_id=actor_agent.id,
board_id=board.id,
)
await self.session.commit()
self.logger.info(
@@ -397,6 +399,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="agent.soul.updated",
message=note,
agent_id=actor_agent_id,
board_id=board.id,
)
await self.session.commit()
self.logger.info(
@@ -437,7 +440,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
tags = payload.reply_tags or ["gateway_main", "user_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "user_via_gateway_main"
base_url = settings.base_url or "http://localhost:8000"
base_url = settings.base_url
message = (
"LEAD REQUEST: ASK USER\n"
f"Board: {board.name}\n"
@@ -470,6 +473,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="gateway.lead.ask_user.failed",
message=f"Lead user question failed for {board.name}: {exc}",
agent_id=actor_agent.id,
board_id=board.id,
)
await self.session.commit()
self.logger.error(
@@ -501,6 +505,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="gateway.lead.ask_user.sent",
message=f"Lead requested user info via gateway agent for board: {board.name}.",
agent_id=actor_agent.id,
board_id=board.id,
)
main_agent = await Agent.objects.filter_by(gateway_id=gateway.id, board_id=None).first(
self.session,
@@ -595,6 +600,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="gateway.main.lead_message.failed",
message=f"Lead message failed for {board.name}: {exc}",
agent_id=actor_agent.id,
board_id=board.id,
)
await self.session.commit()
self.logger.error(
@@ -626,6 +632,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
event_type="gateway.main.lead_message.sent",
message=f"Sent {payload.kind} to lead for board: {board.name}.",
agent_id=actor_agent.id,
board_id=board.id,
)
await self.session.commit()
self.logger.info(
@@ -0,0 +1,167 @@
"""OpenClaw-compatible device identity and connect-signature helpers."""
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
from time import time
from typing import Any, cast
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
DEFAULT_DEVICE_IDENTITY_PATH = Path.home() / ".openclaw" / "identity" / "device.json"
@dataclass(frozen=True)
class DeviceIdentity:
"""Persisted gateway device identity used for connect signatures."""
device_id: str
public_key_pem: str
private_key_pem: str
def _identity_path() -> Path:
raw = os.getenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", "").strip()
if raw:
return Path(raw).expanduser().resolve()
return DEFAULT_DEVICE_IDENTITY_PATH
def _base64url_encode(raw: bytes) -> str:
import base64
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
def _derive_public_key_raw(public_key_pem: str) -> bytes:
loaded = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
if not isinstance(loaded, Ed25519PublicKey):
msg = "device identity public key is not Ed25519"
raise ValueError(msg)
return loaded.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
def _derive_device_id(public_key_pem: str) -> str:
return hashlib.sha256(_derive_public_key_raw(public_key_pem)).hexdigest()
def _write_identity(path: Path, identity: DeviceIdentity) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"deviceId": identity.device_id,
"publicKeyPem": identity.public_key_pem,
"privateKeyPem": identity.private_key_pem,
"createdAtMs": int(time() * 1000),
}
path.write_text(f"{json.dumps(payload, indent=2)}\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
# Best effort on platforms/filesystems that ignore chmod.
pass
def _generate_identity() -> DeviceIdentity:
private_key = Ed25519PrivateKey.generate()
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_key_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
device_id = _derive_device_id(public_key_pem)
return DeviceIdentity(
device_id=device_id,
public_key_pem=public_key_pem,
private_key_pem=private_key_pem,
)
def load_or_create_device_identity() -> DeviceIdentity:
"""Load persisted device identity or create a new one when missing/invalid."""
path = _identity_path()
try:
if path.exists():
payload = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
device_id = str(payload.get("deviceId") or "").strip()
public_key_pem = str(payload.get("publicKeyPem") or "").strip()
private_key_pem = str(payload.get("privateKeyPem") or "").strip()
if device_id and public_key_pem and private_key_pem:
derived_id = _derive_device_id(public_key_pem)
identity = DeviceIdentity(
device_id=derived_id,
public_key_pem=public_key_pem,
private_key_pem=private_key_pem,
)
if derived_id != device_id:
_write_identity(path, identity)
return identity
except (OSError, ValueError, json.JSONDecodeError):
# Fall through to regenerate.
pass
identity = _generate_identity()
_write_identity(path, identity)
return identity
def public_key_raw_base64url_from_pem(public_key_pem: str) -> str:
"""Return raw Ed25519 public key in base64url form expected by OpenClaw."""
return _base64url_encode(_derive_public_key_raw(public_key_pem))
def sign_device_payload(private_key_pem: str, payload: str) -> str:
"""Sign a device payload with Ed25519 and return base64url signature."""
loaded = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
if not isinstance(loaded, Ed25519PrivateKey):
msg = "device identity private key is not Ed25519"
raise ValueError(msg)
signature = loaded.sign(payload.encode("utf-8"))
return _base64url_encode(signature)
def build_device_auth_payload(
*,
device_id: str,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
signed_at_ms: int,
token: str | None,
nonce: str | None,
) -> str:
"""Build the OpenClaw canonical payload string for device signatures."""
version = "v2" if nonce else "v1"
parts = [
version,
device_id,
client_id,
client_mode,
role,
",".join(scopes),
str(signed_at_ms),
token or "",
]
if version == "v2":
parts.append(nonce or "")
return "|".join(parts)
@@ -0,0 +1,31 @@
"""Normalization helpers for user-facing OpenClaw gateway errors."""
from __future__ import annotations
import re
_MISSING_SCOPE_PATTERN = re.compile(
r"missing\s+scope\s*:\s*(?P<scope>[A-Za-z0-9._:-]+)",
re.IGNORECASE,
)
def normalize_gateway_error_message(message: str) -> str:
"""Return a user-friendly message for common gateway auth failures."""
raw_message = message.strip()
if not raw_message:
return "Gateway authentication failed. Verify gateway token and operator scopes."
missing_scope = _MISSING_SCOPE_PATTERN.search(raw_message)
if missing_scope is not None:
scope = missing_scope.group("scope")
return (
f"Gateway token is missing required scope `{scope}`. "
"Update the gateway token scopes and retry."
)
lowered = raw_message.lower()
if "unauthorized" in lowered or "forbidden" in lowered:
return "Gateway authentication failed. Verify gateway token and operator scopes."
return raw_message
+46 -86
View File
@@ -4,26 +4,24 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from app.core.config import settings
from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError, openclaw_call
_VERSION_PATTERN = re.compile(r"(?i)v?(?P<version>\d+(?:\.\d+)+)")
_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = (
("version",),
("gatewayVersion",),
("appVersion",),
("buildVersion",),
("gateway", "version"),
("app", "version"),
("server", "version"),
("runtime", "version"),
("meta", "version"),
("build", "version"),
("info", "version"),
from app.core.logging import get_logger
from app.services.openclaw.gateway_rpc import (
GatewayConfig,
OpenClawGatewayError,
openclaw_call,
openclaw_connect_metadata,
)
_CALVER_PATTERN = re.compile(
r"^v?(?P<year>\d{4})\.(?P<month>\d{1,2})\.(?P<day>\d{1,2})(?:-(?P<rev>\d+))?$",
re.IGNORECASE,
)
_CONNECT_VERSION_PATH: tuple[str, ...] = ("server", "version")
_CONFIG_VERSION_PATH: tuple[str, ...] = ("config", "meta", "lastTouchedVersion")
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class GatewayVersionCheckResult:
@@ -41,11 +39,18 @@ def _normalized_minimum_version() -> str:
def _parse_version_parts(value: str) -> tuple[int, ...] | None:
match = _VERSION_PATTERN.search(value.strip())
match = _CALVER_PATTERN.match(value.strip())
if match is None:
return None
numeric = match.group("version")
return tuple(int(part) for part in numeric.split("."))
year = int(match.group("year"))
month = int(match.group("month"))
day = int(match.group("day"))
revision = int(match.group("rev") or 0)
if month < 1 or month > 12:
return None
if day < 1 or day > 31:
return None
return (year, month, day, revision)
def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int:
@@ -79,36 +84,14 @@ def _coerce_version_string(value: object) -> str | None:
return None
def _iter_fallback_version_values(payload: object) -> list[str]:
if not isinstance(payload, dict):
return []
stack: list[dict[str, Any]] = [payload]
discovered: list[str] = []
while stack:
node = stack.pop()
for key, value in node.items():
if isinstance(value, dict):
stack.append(value)
key_lower = key.lower()
if "version" not in key_lower or "protocol" in key_lower:
continue
candidate = _coerce_version_string(value)
if candidate is not None:
discovered.append(candidate)
return discovered
def extract_connect_server_version(payload: object) -> str | None:
"""Extract the canonical runtime version from connect metadata."""
return _coerce_version_string(_value_at_path(payload, _CONNECT_VERSION_PATH))
def extract_gateway_version(payload: object) -> str | None:
"""Extract a gateway runtime version string from status/health payloads."""
for path in _PRIMARY_VERSION_PATHS:
candidate = _coerce_version_string(_value_at_path(payload, path))
if candidate is not None:
return candidate
for candidate in _iter_fallback_version_values(payload):
if _parse_version_parts(candidate) is not None:
return candidate
return None
def extract_config_last_touched_version(payload: object) -> str | None:
"""Extract a runtime version hint from config.get payload."""
return _coerce_version_string(_value_at_path(payload, _CONFIG_VERSION_PATH))
def evaluate_gateway_version(
@@ -122,7 +105,7 @@ def evaluate_gateway_version(
if min_parts is None:
msg = (
"Server configuration error: GATEWAY_MIN_VERSION is invalid. "
f"Expected a dotted numeric version, got '{min_version}'."
f"Expected CalVer 'YYYY.M.D' or 'YYYY.M.D-REV', got '{min_version}'."
)
return GatewayVersionCheckResult(
compatible=False,
@@ -172,49 +155,26 @@ def evaluate_gateway_version(
)
async def _fetch_runtime_metadata(config: GatewayConfig) -> object:
last_error: OpenClawGatewayError | None = None
for method in ("status", "health"):
try:
return await openclaw_call(method, config=config)
except OpenClawGatewayError as exc:
last_error = exc
continue
if last_error is not None:
raise last_error
return {}
async def _fetch_schema_metadata(config: GatewayConfig) -> object | None:
try:
return await openclaw_call("config.schema", config=config)
except OpenClawGatewayError:
return None
async def check_gateway_runtime_compatibility(
async def check_gateway_version_compatibility(
config: GatewayConfig,
*,
minimum_version: str | None = None,
) -> GatewayVersionCheckResult:
"""Fetch runtime metadata and evaluate gateway version compatibility."""
schema_payload = await _fetch_schema_metadata(config)
current_version = extract_gateway_version(schema_payload)
if current_version is not None:
return evaluate_gateway_version(
current_version=current_version,
minimum_version=minimum_version,
)
payload = await _fetch_runtime_metadata(config)
current_version = extract_gateway_version(payload)
if current_version is None:
"""Evaluate gateway compatibility using connect metadata with config fallback."""
connect_payload = await openclaw_connect_metadata(config=config)
current_version = extract_connect_server_version(connect_payload)
if current_version is None or _parse_version_parts(current_version) is None:
try:
health_payload = await openclaw_call("health", config=config)
except OpenClawGatewayError:
health_payload = None
if health_payload is not None:
current_version = extract_gateway_version(health_payload)
config_payload = await openclaw_call("config.get", config=config)
except OpenClawGatewayError as exc:
logger.debug(
"gateway.compat.config_get_fallback_unavailable reason=%s",
str(exc),
)
else:
fallback_version = extract_config_last_touched_version(config_payload)
if fallback_version is not None:
current_version = fallback_version
return evaluate_gateway_version(
current_version=current_version,
minimum_version=minimum_version,
@@ -32,7 +32,12 @@ def gateway_client_config(gateway: Gateway) -> GatewayClientConfig:
detail="Gateway url is required",
)
token = (gateway.token or "").strip() or None
return GatewayClientConfig(url=url, token=token)
return GatewayClientConfig(
url=url,
token=token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConfig | None:
@@ -43,7 +48,12 @@ def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConf
if not url:
return None
token = (gateway.token or "").strip() or None
return GatewayClientConfig(url=url, token=token)
return GatewayClientConfig(
url=url,
token=token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
def require_gateway_workspace_root(gateway: Gateway) -> str:
+226 -27
View File
@@ -9,9 +9,10 @@ from __future__ import annotations
import asyncio
import json
import ssl
from dataclasses import dataclass
from time import perf_counter
from typing import Any
from time import perf_counter, time
from typing import Any, Literal
from urllib.parse import urlencode, urlparse, urlunparse
from uuid import uuid4
@@ -19,14 +20,26 @@ import websockets
from websockets.exceptions import WebSocketException
from app.core.logging import TRACE_LEVEL, get_logger
from app.services.openclaw.device_identity import (
build_device_auth_payload,
load_or_create_device_identity,
public_key_raw_base64url_from_pem,
sign_device_payload,
)
PROTOCOL_VERSION = 3
logger = get_logger(__name__)
GATEWAY_OPERATOR_SCOPES = (
"operator.read",
"operator.admin",
"operator.approvals",
"operator.pairing",
)
DEFAULT_GATEWAY_CLIENT_ID = "gateway-client"
DEFAULT_GATEWAY_CLIENT_MODE = "backend"
CONTROL_UI_CLIENT_ID = "openclaw-control-ui"
CONTROL_UI_CLIENT_MODE = "ui"
GatewayConnectMode = Literal["device", "control_ui"]
# NOTE: These are the base gateway methods from the OpenClaw gateway repo.
# The gateway can expose additional methods at runtime via channel plugins.
@@ -159,6 +172,8 @@ class GatewayConfig:
url: str
token: str | None = None
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
def _build_gateway_url(config: GatewayConfig) -> str:
@@ -179,6 +194,78 @@ def _redacted_url_for_log(raw_url: str) -> str:
return str(urlunparse(parsed._replace(query="", fragment="")))
def _create_ssl_context(config: GatewayConfig) -> ssl.SSLContext | None:
"""Create an insecure SSL context override for explicit opt-in TLS bypass.
This behavior is intentionally host-agnostic: when ``allow_insecure_tls`` is
enabled for a ``wss://`` gateway, certificate and hostname verification are
disabled for that gateway connection.
"""
parsed = urlparse(config.url)
if parsed.scheme != "wss":
return None
if not config.allow_insecure_tls:
return None
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
return ssl_context
def _build_control_ui_origin(gateway_url: str) -> str | None:
parsed = urlparse(gateway_url)
if not parsed.hostname:
return None
if parsed.scheme in {"ws", "http"}:
origin_scheme = "http"
elif parsed.scheme in {"wss", "https"}:
origin_scheme = "https"
else:
return None
host = parsed.hostname
if ":" in host and not host.startswith("["):
host = f"[{host}]"
if parsed.port is not None:
host = f"{host}:{parsed.port}"
return f"{origin_scheme}://{host}"
def _resolve_connect_mode(config: GatewayConfig) -> GatewayConnectMode:
return "control_ui" if config.disable_device_pairing else "device"
def _build_device_connect_payload(
*,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
auth_token: str | None,
connect_nonce: str | None,
) -> dict[str, Any]:
identity = load_or_create_device_identity()
signed_at_ms = int(time() * 1000)
payload = build_device_auth_payload(
device_id=identity.device_id,
client_id=client_id,
client_mode=client_mode,
role=role,
scopes=scopes,
signed_at_ms=signed_at_ms,
token=auth_token,
nonce=connect_nonce,
)
device_payload: dict[str, Any] = {
"id": identity.device_id,
"publicKey": public_key_raw_base64url_from_pem(identity.public_key_pem),
"signature": sign_device_payload(identity.private_key_pem, payload),
"signedAt": signed_at_ms,
}
if connect_nonce:
device_payload["nonce"] = connect_nonce
return device_payload
async def _await_response(
ws: websockets.ClientConnection,
request_id: str,
@@ -230,19 +317,36 @@ async def _send_request(
return await _await_response(ws, request_id)
def _build_connect_params(config: GatewayConfig) -> dict[str, Any]:
def _build_connect_params(
config: GatewayConfig,
*,
connect_nonce: str | None = None,
) -> dict[str, Any]:
role = "operator"
scopes = list(GATEWAY_OPERATOR_SCOPES)
connect_mode = _resolve_connect_mode(config)
use_control_ui = connect_mode == "control_ui"
params: dict[str, Any] = {
"minProtocol": PROTOCOL_VERSION,
"maxProtocol": PROTOCOL_VERSION,
"role": "operator",
"scopes": list(GATEWAY_OPERATOR_SCOPES),
"role": role,
"scopes": scopes,
"client": {
"id": "gateway-client",
"id": CONTROL_UI_CLIENT_ID if use_control_ui else DEFAULT_GATEWAY_CLIENT_ID,
"version": "1.0.0",
"platform": "web",
"mode": "ui",
"platform": "python",
"mode": CONTROL_UI_CLIENT_MODE if use_control_ui else DEFAULT_GATEWAY_CLIENT_MODE,
},
}
if not use_control_ui:
params["device"] = _build_device_connect_payload(
client_id=DEFAULT_GATEWAY_CLIENT_ID,
client_mode=DEFAULT_GATEWAY_CLIENT_MODE,
role=role,
scopes=scopes,
auth_token=config.token,
connect_nonce=connect_nonce,
)
if config.token:
params["auth"] = {"token": config.token}
return params
@@ -252,12 +356,19 @@ async def _ensure_connected(
ws: websockets.ClientConnection,
first_message: str | bytes | None,
config: GatewayConfig,
) -> None:
) -> object:
connect_nonce: str | None = None
if first_message:
if isinstance(first_message, bytes):
first_message = first_message.decode("utf-8")
data = json.loads(first_message)
if data.get("type") != "event" or data.get("event") != "connect.challenge":
if data.get("type") == "event" and data.get("event") == "connect.challenge":
payload = data.get("payload")
if isinstance(payload, dict):
nonce = payload.get("nonce")
if isinstance(nonce, str) and nonce.strip():
connect_nonce = nonce.strip()
else:
logger.warning(
"gateway.rpc.connect.unexpected_first_message type=%s event=%s",
data.get("type"),
@@ -268,10 +379,56 @@ async def _ensure_connected(
"type": "req",
"id": connect_id,
"method": "connect",
"params": _build_connect_params(config),
"params": _build_connect_params(config, connect_nonce=connect_nonce),
}
await ws.send(json.dumps(response))
await _await_response(ws, connect_id)
return await _await_response(ws, connect_id)
async def _recv_first_message_or_none(
ws: websockets.ClientConnection,
) -> str | bytes | None:
try:
return await asyncio.wait_for(ws.recv(), timeout=2)
except TimeoutError:
return None
async def _openclaw_call_once(
method: str,
params: dict[str, Any] | None,
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None
ssl_context = _create_ssl_context(config)
connect_kwargs: dict[str, Any] = {"ping_interval": None}
if origin is not None:
connect_kwargs["origin"] = origin
if ssl_context is not None:
connect_kwargs["ssl"] = ssl_context
async with websockets.connect(gateway_url, **connect_kwargs) as ws:
first_message = await _recv_first_message_or_none(ws)
await _ensure_connected(ws, first_message, config)
return await _send_request(ws, method, params)
async def _openclaw_connect_metadata_once(
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None
ssl_context = _create_ssl_context(config)
connect_kwargs: dict[str, Any] = {"ping_interval": None}
if origin is not None:
connect_kwargs["origin"] = origin
if ssl_context is not None:
connect_kwargs["ssl"] = ssl_context
async with websockets.connect(gateway_url, **connect_kwargs) as ws:
first_message = await _recv_first_message_or_none(ws)
return await _ensure_connected(ws, first_message, config)
async def openclaw_call(
@@ -284,25 +441,28 @@ async def openclaw_call(
gateway_url = _build_gateway_url(config)
started_at = perf_counter()
logger.debug(
"gateway.rpc.call.start method=%s gateway_url=%s",
(
"gateway.rpc.call.start method=%s gateway_url=%s allow_insecure_tls=%s "
"disable_device_pairing=%s"
),
method,
_redacted_url_for_log(gateway_url),
config.allow_insecure_tls,
config.disable_device_pairing,
)
try:
async with websockets.connect(gateway_url, ping_interval=None) as ws:
first_message = None
try:
first_message = await asyncio.wait_for(ws.recv(), timeout=2)
except TimeoutError:
first_message = None
await _ensure_connected(ws, first_message, config)
payload = await _send_request(ws, method, params)
logger.debug(
"gateway.rpc.call.success method=%s duration_ms=%s",
method,
int((perf_counter() - started_at) * 1000),
)
return payload
payload = await _openclaw_call_once(
method,
params,
config=config,
gateway_url=gateway_url,
)
logger.debug(
"gateway.rpc.call.success method=%s duration_ms=%s",
method,
int((perf_counter() - started_at) * 1000),
)
return payload
except OpenClawGatewayError:
logger.warning(
"gateway.rpc.call.gateway_error method=%s duration_ms=%s",
@@ -326,6 +486,45 @@ async def openclaw_call(
raise OpenClawGatewayError(str(exc)) from exc
async def openclaw_connect_metadata(*, config: GatewayConfig) -> object:
"""Open a gateway connection and return the connect/hello payload."""
gateway_url = _build_gateway_url(config)
started_at = perf_counter()
logger.debug(
"gateway.rpc.connect_metadata.start gateway_url=%s",
_redacted_url_for_log(gateway_url),
)
try:
metadata = await _openclaw_connect_metadata_once(
config=config,
gateway_url=gateway_url,
)
logger.debug(
"gateway.rpc.connect_metadata.success duration_ms=%s",
int((perf_counter() - started_at) * 1000),
)
return metadata
except OpenClawGatewayError:
logger.warning(
"gateway.rpc.connect_metadata.gateway_error duration_ms=%s",
int((perf_counter() - started_at) * 1000),
)
raise
except (
TimeoutError,
ConnectionError,
OSError,
ValueError,
WebSocketException,
) as exc: # pragma: no cover - network/protocol errors
logger.error(
"gateway.rpc.connect_metadata.transport_error duration_ms=%s error_type=%s",
int((perf_counter() - started_at) * 1000),
exc.__class__.__name__,
)
raise OpenClawGatewayError(str(exc)) from exc
async def send_message(
message: str,
*,
@@ -0,0 +1,167 @@
"""Unified agent lifecycle orchestration.
This module centralizes DB-backed lifecycle transitions so call sites do not
duplicate provisioning/wake/state logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import HTTPException, status
from sqlmodel import col, select
from app.core.time import utcnow
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services.openclaw.constants import CHECKIN_DEADLINE_AFTER_WAKE
from app.services.openclaw.db_agent_state import (
mark_provision_complete,
mark_provision_requested,
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.lifecycle_queue import (
QueuedAgentLifecycleReconcile,
enqueue_lifecycle_reconcile,
)
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
from app.services.organizations import get_org_owner_user
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.users import User
class AgentLifecycleOrchestrator(OpenClawDBService):
"""Single lifecycle writer for agent provision/update transitions."""
def __init__(self, session: AsyncSession) -> None:
super().__init__(session)
async def _lock_agent(self, *, agent_id: UUID) -> Agent:
statement = select(Agent).where(col(Agent.id) == agent_id).with_for_update()
agent = (await self.session.exec(statement)).first()
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
return agent
async def run_lifecycle(
self,
*,
gateway: Gateway,
agent_id: UUID,
board: Board | None,
user: User | None,
action: str,
auth_token: str | None = None,
force_bootstrap: bool = False,
reset_session: bool = False,
wake: bool = True,
deliver_wakeup: bool = True,
wakeup_verb: str | None = None,
clear_confirm_token: bool = False,
raise_gateway_errors: bool = True,
) -> Agent:
"""Provision or update any agent under a per-agent lock."""
locked = await self._lock_agent(agent_id=agent_id)
template_user = user
if board is None and template_user is None:
template_user = await get_org_owner_user(
self.session,
organization_id=gateway.organization_id,
)
if template_user is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
"Organization owner not found "
"(required for gateway agent USER.md rendering)."
),
)
raw_token = auth_token or mint_agent_token(locked)
mark_provision_requested(
locked,
action=action,
status="updating" if action == "update" else "provisioning",
)
locked.lifecycle_generation += 1
locked.last_provision_error = None
locked.checkin_deadline_at = utcnow() + CHECKIN_DEADLINE_AFTER_WAKE if wake else None
if wake:
locked.wake_attempts += 1
locked.last_wake_sent_at = utcnow()
self.session.add(locked)
await self.session.flush()
if not gateway.url:
await self.session.commit()
await self.session.refresh(locked)
return locked
try:
await OpenClawGatewayProvisioner().apply_agent_lifecycle(
agent=locked,
gateway=gateway,
board=board,
auth_token=raw_token,
user=template_user,
action=action,
force_bootstrap=force_bootstrap,
reset_session=reset_session,
wake=wake,
deliver_wakeup=deliver_wakeup,
wakeup_verb=wakeup_verb,
)
except OpenClawGatewayError as exc:
locked.last_provision_error = str(exc)
locked.updated_at = utcnow()
self.session.add(locked)
await self.session.commit()
await self.session.refresh(locked)
if raise_gateway_errors:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway {action} failed: {exc}",
) from exc
return locked
except (OSError, RuntimeError, ValueError) as exc:
locked.last_provision_error = str(exc)
locked.updated_at = utcnow()
self.session.add(locked)
await self.session.commit()
await self.session.refresh(locked)
if raise_gateway_errors:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unexpected error {action}ing gateway provisioning.",
) from exc
return locked
mark_provision_complete(
locked,
status="online",
clear_confirm_token=clear_confirm_token,
)
locked.last_provision_error = None
locked.checkin_deadline_at = utcnow() + CHECKIN_DEADLINE_AFTER_WAKE if wake else None
self.session.add(locked)
await self.session.commit()
await self.session.refresh(locked)
if wake and locked.checkin_deadline_at is not None:
enqueue_lifecycle_reconcile(
QueuedAgentLifecycleReconcile(
agent_id=locked.id,
gateway_id=locked.gateway_id,
board_id=locked.board_id,
generation=locked.lifecycle_generation,
checkin_deadline_at=locked.checkin_deadline_at,
)
)
return locked
@@ -0,0 +1,122 @@
"""Queue payload helpers for stuck-agent lifecycle reconciliation."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from uuid import UUID
from app.core.config import settings
from app.core.logging import get_logger
from app.core.time import utcnow
from app.services.queue import QueuedTask, enqueue_task_with_delay
from app.services.queue import requeue_if_failed as generic_requeue_if_failed
logger = get_logger(__name__)
TASK_TYPE = "agent_lifecycle_reconcile"
@dataclass(frozen=True)
class QueuedAgentLifecycleReconcile:
"""Queued payload metadata for lifecycle reconciliation checks."""
agent_id: UUID
gateway_id: UUID
board_id: UUID | None
generation: int
checkin_deadline_at: datetime
attempts: int = 0
def _task_from_payload(payload: QueuedAgentLifecycleReconcile) -> QueuedTask:
return QueuedTask(
task_type=TASK_TYPE,
payload={
"agent_id": str(payload.agent_id),
"gateway_id": str(payload.gateway_id),
"board_id": str(payload.board_id) if payload.board_id is not None else None,
"generation": payload.generation,
"checkin_deadline_at": payload.checkin_deadline_at.isoformat(),
},
created_at=utcnow(),
attempts=payload.attempts,
)
def decode_lifecycle_task(task: QueuedTask) -> QueuedAgentLifecycleReconcile:
if task.task_type not in {TASK_TYPE, "legacy"}:
raise ValueError(f"Unexpected task_type={task.task_type!r}; expected {TASK_TYPE!r}")
payload: dict[str, Any] = task.payload
raw_board_id = payload.get("board_id")
board_id = UUID(raw_board_id) if isinstance(raw_board_id, str) and raw_board_id else None
raw_deadline = payload.get("checkin_deadline_at")
if not isinstance(raw_deadline, str):
raise ValueError("checkin_deadline_at is required")
return QueuedAgentLifecycleReconcile(
agent_id=UUID(str(payload["agent_id"])),
gateway_id=UUID(str(payload["gateway_id"])),
board_id=board_id,
generation=int(payload["generation"]),
checkin_deadline_at=datetime.fromisoformat(raw_deadline),
attempts=int(payload.get("attempts", task.attempts)),
)
def enqueue_lifecycle_reconcile(payload: QueuedAgentLifecycleReconcile) -> bool:
"""Enqueue a delayed reconcile check keyed to the expected check-in deadline."""
now = utcnow()
delay_seconds = max(0.0, (payload.checkin_deadline_at - now).total_seconds())
queued = _task_from_payload(payload)
ok = enqueue_task_with_delay(
queued,
settings.rq_queue_name,
delay_seconds=delay_seconds,
redis_url=settings.rq_redis_url,
)
if ok:
logger.info(
"lifecycle.queue.enqueued",
extra={
"agent_id": str(payload.agent_id),
"generation": payload.generation,
"delay_seconds": delay_seconds,
"attempt": payload.attempts,
},
)
return ok
def defer_lifecycle_reconcile(
task: QueuedTask,
*,
delay_seconds: float,
) -> bool:
"""Defer a reconcile task without incrementing retry attempts."""
payload = decode_lifecycle_task(task)
deferred = QueuedAgentLifecycleReconcile(
agent_id=payload.agent_id,
gateway_id=payload.gateway_id,
board_id=payload.board_id,
generation=payload.generation,
checkin_deadline_at=payload.checkin_deadline_at,
attempts=task.attempts,
)
queued = _task_from_payload(deferred)
return enqueue_task_with_delay(
queued,
settings.rq_queue_name,
delay_seconds=max(0.0, delay_seconds),
redis_url=settings.rq_redis_url,
)
def requeue_lifecycle_queue_task(task: QueuedTask, *, delay_seconds: float = 0) -> bool:
"""Requeue a failed lifecycle task with capped retries."""
return generic_requeue_if_failed(
task,
settings.rq_queue_name,
max_retries=settings.rq_dispatch_max_retries,
redis_url=settings.rq_redis_url,
delay_seconds=max(0.0, delay_seconds),
)
@@ -0,0 +1,140 @@
"""Worker handlers for lifecycle reconciliation tasks."""
from __future__ import annotations
import asyncio
from app.core.logging import get_logger
from app.core.time import utcnow
from app.db.session import async_session_maker
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services.openclaw.constants import MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN
from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator
from app.services.openclaw.lifecycle_queue import decode_lifecycle_task, defer_lifecycle_reconcile
from app.services.queue import QueuedTask
logger = get_logger(__name__)
_RECONCILE_TIMEOUT_SECONDS = 60.0
def _has_checked_in_since_wake(agent: Agent) -> bool:
if agent.last_seen_at is None:
return False
if agent.last_wake_sent_at is None:
return True
return agent.last_seen_at >= agent.last_wake_sent_at
async def process_lifecycle_queue_task(task: QueuedTask) -> None:
"""Re-run lifecycle provisioning when an agent misses post-provision check-in."""
payload = decode_lifecycle_task(task)
now = utcnow()
async with async_session_maker() as session:
agent = await Agent.objects.by_id(payload.agent_id).first(session)
if agent is None:
logger.info(
"lifecycle.reconcile.skip_missing_agent",
extra={"agent_id": str(payload.agent_id)},
)
return
# Ignore stale queue messages after a newer lifecycle generation.
if agent.lifecycle_generation != payload.generation:
logger.info(
"lifecycle.reconcile.skip_stale_generation",
extra={
"agent_id": str(agent.id),
"queued_generation": payload.generation,
"current_generation": agent.lifecycle_generation,
},
)
return
if _has_checked_in_since_wake(agent):
logger.info(
"lifecycle.reconcile.skip_not_stuck",
extra={"agent_id": str(agent.id), "status": agent.status},
)
return
deadline = agent.checkin_deadline_at or payload.checkin_deadline_at
if agent.status == "deleting":
logger.info(
"lifecycle.reconcile.skip_deleting",
extra={"agent_id": str(agent.id)},
)
return
if now < deadline:
delay = max(0.0, (deadline - now).total_seconds())
if not defer_lifecycle_reconcile(task, delay_seconds=delay):
msg = "Failed to defer lifecycle reconcile task"
raise RuntimeError(msg)
logger.info(
"lifecycle.reconcile.deferred",
extra={"agent_id": str(agent.id), "delay_seconds": delay},
)
return
if agent.wake_attempts >= MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN:
agent.status = "offline"
agent.checkin_deadline_at = None
agent.last_provision_error = (
"Agent did not check in after wake; max wake attempts reached"
)
agent.updated_at = utcnow()
session.add(agent)
await session.commit()
logger.warning(
"lifecycle.reconcile.max_attempts_reached",
extra={
"agent_id": str(agent.id),
"wake_attempts": agent.wake_attempts,
"max_attempts": MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN,
},
)
return
gateway = await Gateway.objects.by_id(agent.gateway_id).first(session)
if gateway is None:
logger.warning(
"lifecycle.reconcile.skip_missing_gateway",
extra={"agent_id": str(agent.id), "gateway_id": str(agent.gateway_id)},
)
return
board: Board | None = None
if agent.board_id is not None:
board = await Board.objects.by_id(agent.board_id).first(session)
if board is None:
logger.warning(
"lifecycle.reconcile.skip_missing_board",
extra={"agent_id": str(agent.id), "board_id": str(agent.board_id)},
)
return
orchestrator = AgentLifecycleOrchestrator(session)
await asyncio.wait_for(
orchestrator.run_lifecycle(
gateway=gateway,
agent_id=agent.id,
board=board,
user=None,
action="update",
auth_token=None,
force_bootstrap=False,
reset_session=True,
wake=True,
deliver_wakeup=True,
wakeup_verb="updated",
clear_confirm_token=True,
raise_gateway_errors=True,
),
timeout=_RECONCILE_TIMEOUT_SECONDS,
)
logger.info(
"lifecycle.reconcile.retriggered",
extra={"agent_id": str(agent.id), "generation": payload.generation},
)
+115 -14
View File
@@ -7,6 +7,7 @@ DB-backed workflows (template sync, lead-agent record creation) live in
from __future__ import annotations
import asyncio
import json
import re
from abc import ABC, abstractmethod
@@ -17,6 +18,7 @@ from typing import TYPE_CHECKING, Any
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from app.core.config import settings
from app.core.logging import get_logger
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
@@ -54,6 +56,8 @@ from app.services.openclaw.shared import GatewayAgentIdentity
if TYPE_CHECKING:
from app.models.users import User
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class ProvisionOptions:
@@ -109,28 +113,62 @@ def _heartbeat_config(agent: Agent) -> dict[str, Any]:
return merged
def _tools_exec_host_patch(config_data: dict[str, Any]) -> dict[str, Any] | None:
"""Ensure ``tools.exec.host`` is set to ``"gateway"`` so agents can run commands.
Without this, heartbeat-driven agents cannot execute ``curl``, ``bash``, or
any other shell command making HEARTBEAT.md instructions unexecutable.
Returns a partial ``tools`` dict to merge into ``config.patch``, or ``None``
if the setting is already present.
"""
tools = config_data.get("tools")
if not isinstance(tools, dict):
return {"exec": {"host": "gateway"}}
exec_cfg = tools.get("exec")
if not isinstance(exec_cfg, dict):
return {"exec": {"host": "gateway"}}
if exec_cfg.get("host"):
return None # Already configured — don't override user choice.
return {"exec": {"host": "gateway"}}
def _channel_heartbeat_visibility_patch(config_data: dict[str, Any]) -> dict[str, Any] | None:
"""Build a minimal patch ensuring channel default heartbeat visibility is configured.
Gateways may have existing channel config; we only want to fill missing keys rather than
overwrite operator intent. Returns `None` if no change is needed, otherwise returns a shallow
patch dict suitable for a config merge."""
channels = config_data.get("channels")
if not isinstance(channels, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
defaults = channels.get("defaults")
if not isinstance(defaults, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
heartbeat = defaults.get("heartbeat")
if not isinstance(heartbeat, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
merged = dict(heartbeat)
changed = False
for key, value in DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.items():
if key not in merged:
merged[key] = value
changed = True
if not changed:
return None
return {"defaults": {"heartbeat": merged}}
def _template_env() -> Environment:
"""Create the Jinja environment used for gateway template rendering.
Note: we intentionally disable auto-escaping so markdown/plaintext templates render verbatim.
"""
return Environment(
loader=FileSystemLoader(_templates_root()),
# Render markdown verbatim (HTML escaping makes it harder for agents to read).
@@ -145,19 +183,34 @@ def _heartbeat_template_name(agent: Agent) -> str:
def _workspace_path(agent: Agent, workspace_root: str) -> str:
"""Return the absolute on-disk workspace directory for an agent.
Why this exists:
- We derive the folder name from a stable *agent key* (ultimately rooted in ids/session keys)
rather than display names to avoid collisions.
- We preserve a historical gateway-main naming quirk to avoid moving existing directories.
This path is later interpolated into template files (TOOLS.md, etc.) that agents treat as the
source of truth for where to read/write.
"""
if not workspace_root:
msg = "gateway_workspace_root is required"
raise ValueError(msg)
root = workspace_root.rstrip("/")
# Use agent key derived from session key when possible. This prevents collisions for
# lead agents (session key includes board id) even if multiple boards share the same
# display name (e.g. "Lead Agent").
key = _agent_key(agent)
# Backwards-compat: gateway-main agents historically used session keys that encoded
# "gateway-<id>" while the gateway agent id is "mc-gateway-<id>".
# Keep the on-disk workspace path stable so existing provisioned files aren't moved.
if key.startswith("mc-gateway-"):
key = key.removeprefix("mc-")
return f"{root}/workspace-{slugify(key)}"
@@ -317,7 +370,7 @@ def _build_context(
workspace_root = gateway.workspace_root
workspace_path = _workspace_path(agent, workspace_root)
session_key = agent.openclaw_session_id or ""
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
base_url = settings.base_url
main_session_key = GatewayAgentIdentity.session_key(gateway)
identity_context = _identity_context(agent)
user_context = _user_context(user)
@@ -333,6 +386,7 @@ def _build_context(
"board_goal_confirmed": str(board.goal_confirmed).lower(),
"board_rule_require_approval_for_done": str(board.require_approval_for_done).lower(),
"board_rule_require_review_before_done": str(board.require_review_before_done).lower(),
"board_rule_comment_required_for_review": str(board.comment_required_for_review).lower(),
"board_rule_block_status_changes_with_pending_approval": str(
board.block_status_changes_with_pending_approval
).lower(),
@@ -357,7 +411,7 @@ def _build_main_context(
auth_token: str,
user: User | None,
) -> dict[str, str]:
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
base_url = settings.base_url
identity_context = _identity_context(agent)
user_context = _user_context(user)
return {
@@ -523,6 +577,7 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
# Prefer an idempotent "create then update" flow.
# - Avoids enumerating gateway agents for existence checks.
# - Ensures we always hit the "create" RPC first, per lifecycle expectations.
agent_just_created = False
try:
await openclaw_call(
"agents.create",
@@ -532,21 +587,47 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
},
config=self._config,
)
agent_just_created = True
except OpenClawGatewayError as exc:
message = str(exc).lower()
if not any(
marker in message for marker in ("already", "exist", "duplicate", "conflict")
):
raise
await openclaw_call(
"agents.update",
{
"agentId": registration.agent_id,
"name": registration.name,
"workspace": registration.workspace_path,
},
config=self._config,
)
# Gateway hot-reload has a ~500ms debounce after agents.create writes to disk.
# agents.update arriving before the reload completes returns "agent not found".
# Wait for the reload window before attempting the update.
if agent_just_created:
await asyncio.sleep(0.75)
# Retry agents.update only when this call just created the agent.
# If create reported "already exists", "not found" should fail fast.
_update_retries = 5
_update_delay = 0.5
for _attempt in range(_update_retries):
try:
await openclaw_call(
"agents.update",
{
"agentId": registration.agent_id,
"name": registration.name,
"workspace": registration.workspace_path,
},
config=self._config,
)
break
except OpenClawGatewayError as exc:
should_retry = (
agent_just_created
and _is_missing_agent_error(exc)
and _attempt < _update_retries - 1
)
if should_retry:
await asyncio.sleep(_update_delay)
_update_delay = min(_update_delay * 2, 4.0)
continue
raise
await self.patch_agent_heartbeats(
[(registration.agent_id, registration.workspace_path, registration.heartbeat)],
)
@@ -609,10 +690,20 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
entry_by_id = _heartbeat_entry_map(entries)
new_list = _updated_agent_list(raw_list, entry_by_id)
patch: dict[str, Any] = {"agents": {"list": new_list}}
channels_patch = _channel_heartbeat_visibility_patch(config_data)
tools_patch = _tools_exec_host_patch(config_data)
# Skip config.patch entirely when nothing changed — avoids an unnecessary
# gateway SIGUSR1 restart that rotates agent tokens and breaks active sessions.
if new_list == raw_list and channels_patch is None and tools_patch is None:
logger.debug("patch_agent_heartbeats: no changes detected, skipping config.patch")
return
patch: dict[str, Any] = {"agents": {"list": new_list}}
if channels_patch is not None:
patch["channels"] = channels_patch
if tools_patch is not None:
patch["tools"] = tools_patch
params = {"raw": json.dumps(patch)}
if base_hash:
params["baseHash"] = base_hash
@@ -970,7 +1061,12 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
return OpenClawGatewayControlPlane(
GatewayClientConfig(url=gateway.url, token=gateway.token),
GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
),
)
@@ -1099,7 +1195,12 @@ class OpenClawGatewayProvisioner:
if not wake:
return
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
client_config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
await ensure_session(session_key, config=client_config, label=agent.name)
verb = wakeup_verb or ("provisioned" if action == "provision" else "updated")
await send_message(
+107 -98
View File
@@ -52,8 +52,6 @@ from app.services.openclaw.constants import (
OFFLINE_AFTER,
)
from app.services.openclaw.db_agent_state import (
mark_provision_complete,
mark_provision_requested,
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
@@ -74,6 +72,7 @@ from app.services.openclaw.internal.session_keys import (
board_agent_session_key,
board_lead_session_key,
)
from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning import (
OpenClawGatewayControlPlane,
@@ -143,7 +142,6 @@ class OpenClawProvisioningService(OpenClawDBService):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session)
self._gateway = OpenClawGatewayProvisioner()
@staticmethod
def lead_session_key(board: Board) -> str:
@@ -213,25 +211,25 @@ class OpenClawProvisioningService(OpenClawDBService):
openclaw_session_id=self.lead_session_key(board),
)
raw_token = mint_agent_token(agent)
mark_provision_requested(agent, action=config_options.action, status="provisioning")
await self.add_commit_refresh(agent)
# Strict behavior: provisioning errors surface to the caller. The DB row exists
# so a later retry can succeed with the same deterministic identity/session key.
await self._gateway.apply_agent_lifecycle(
agent=agent,
agent = await AgentLifecycleOrchestrator(self.session).run_lifecycle(
gateway=request.gateway,
agent_id=agent.id,
board=board,
auth_token=raw_token,
user=request.user,
action=config_options.action,
auth_token=raw_token,
force_bootstrap=False,
reset_session=False,
wake=True,
deliver_wakeup=True,
wakeup_verb=None,
clear_confirm_token=False,
raise_gateway_errors=True,
)
mark_provision_complete(agent, status="online")
await self.add_commit_refresh(agent)
return agent, True
async def sync_gateway_templates(
@@ -285,7 +283,12 @@ class OpenClawProvisioningService(OpenClawDBService):
return result
control_plane = OpenClawGatewayControlPlane(
GatewayClientConfig(url=gateway.url, token=gateway.token),
GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
),
)
ctx = _SyncContext(
session=self.session,
@@ -293,7 +296,6 @@ class OpenClawProvisioningService(OpenClawDBService):
control_plane=control_plane,
backoff=GatewayBackoff(timeout_s=10 * 60, timeout_context="template sync"),
options=options,
provisioner=self._gateway,
)
if not await _ping_gateway(ctx, result):
return result
@@ -347,7 +349,6 @@ class _SyncContext:
control_plane: OpenClawGatewayControlPlane
backoff: GatewayBackoff
options: GatewayTemplateSyncOptions
provisioner: OpenClawGatewayProvisioner
def _parse_tools_md(content: str) -> dict[str, str]:
@@ -579,18 +580,26 @@ async def _sync_one_agent(
try:
async def _do_provision() -> bool:
await ctx.provisioner.apply_agent_lifecycle(
agent=agent,
gateway=ctx.gateway,
board=board,
auth_token=auth_token,
user=ctx.options.user,
action="update",
force_bootstrap=ctx.options.force_bootstrap,
overwrite=ctx.options.overwrite,
reset_session=ctx.options.reset_sessions,
wake=False,
)
try:
await AgentLifecycleOrchestrator(ctx.session).run_lifecycle(
gateway=ctx.gateway,
agent_id=agent.id,
board=board,
user=ctx.options.user,
action="update",
auth_token=auth_token,
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
wake=False,
deliver_wakeup=False,
wakeup_verb="updated",
clear_confirm_token=False,
raise_gateway_errors=True,
)
except HTTPException as exc:
if exc.status_code == status.HTTP_502_BAD_GATEWAY:
raise OpenClawGatewayError(str(exc.detail)) from exc
raise
return True
await ctx.backoff.run(_do_provision)
@@ -608,6 +617,15 @@ async def _sync_one_agent(
message=f"Failed to sync templates: {exc}",
)
return False
except HTTPException as exc:
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
board=board,
message=f"Failed to sync templates: {exc.detail}",
)
return False
else:
return False
@@ -650,18 +668,26 @@ async def _sync_main_agent(
try:
async def _do_provision_main() -> bool:
await ctx.provisioner.apply_agent_lifecycle(
agent=main_agent,
gateway=ctx.gateway,
board=None,
auth_token=token,
user=ctx.options.user,
action="update",
force_bootstrap=ctx.options.force_bootstrap,
overwrite=ctx.options.overwrite,
reset_session=ctx.options.reset_sessions,
wake=False,
)
try:
await AgentLifecycleOrchestrator(ctx.session).run_lifecycle(
gateway=ctx.gateway,
agent_id=main_agent.id,
board=None,
user=ctx.options.user,
action="update",
auth_token=token,
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
wake=False,
deliver_wakeup=False,
wakeup_verb="updated",
clear_confirm_token=False,
raise_gateway_errors=True,
)
except HTTPException as exc:
if exc.status_code == status.HTTP_502_BAD_GATEWAY:
raise OpenClawGatewayError(str(exc.detail)) from exc
raise
return True
await ctx.backoff.run(_do_provision_main)
@@ -674,6 +700,12 @@ async def _sync_main_agent(
agent=main_agent,
message=f"Failed to sync gateway agent templates: {exc}",
)
except HTTPException as exc:
_append_sync_error(
result,
agent=main_agent,
message=f"Failed to sync gateway agent templates: {exc.detail}",
)
else:
result.main_updated = True
return stop_sync
@@ -910,6 +942,7 @@ class AgentLifecycleService(OpenClawDBService):
event_type="agent.heartbeat",
message=f"Heartbeat received from {agent.name}.",
agent_id=agent.id,
board_id=agent.board_id,
)
@staticmethod
@@ -925,6 +958,7 @@ class AgentLifecycleService(OpenClawDBService):
event_type=f"agent.{action}.failed",
message=f"{action_label} message failed: {error}",
agent_id=agent.id,
board_id=agent.board_id,
)
async def coerce_agent_create_payload(
@@ -1033,7 +1067,6 @@ class AgentLifecycleService(OpenClawDBService):
) -> tuple[Agent, str]:
agent = Agent.model_validate(data)
raw_token = mint_agent_token(agent)
mark_provision_requested(agent, action="provision", status="provisioning")
agent.openclaw_session_id = self.resolve_session_key(agent)
await self.add_commit_refresh(agent)
return agent, raw_token
@@ -1063,92 +1096,65 @@ class AgentLifecycleService(OpenClawDBService):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="board is required for non-main agent provisioning",
)
template_user = user
if target.is_main_agent and template_user is None:
template_user = await get_org_owner_user(
self.session,
organization_id=target.gateway.organization_id,
)
if template_user is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
"User context is required to provision the gateway main agent "
"(org owner not found)."
),
)
await OpenClawGatewayProvisioner().apply_agent_lifecycle(
agent=agent,
provisioned = await AgentLifecycleOrchestrator(self.session).run_lifecycle(
gateway=target.gateway,
agent_id=agent.id,
board=target.board if not target.is_main_agent else None,
auth_token=auth_token,
user=template_user,
user=user,
action=action,
auth_token=auth_token,
force_bootstrap=force_bootstrap,
reset_session=True,
wake=True,
deliver_wakeup=True,
wakeup_verb=wakeup_verb,
clear_confirm_token=True,
raise_gateway_errors=raise_gateway_errors,
)
mark_provision_complete(agent, status="online", clear_confirm_token=True)
self.session.add(agent)
await self.session.commit()
record_activity(
self.session,
event_type=f"agent.{action}.direct",
message=f"{action.capitalize()}d directly for {agent.name}.",
agent_id=agent.id,
message=f"{action.capitalize()}d directly for {provisioned.name}.",
agent_id=provisioned.id,
board_id=provisioned.board_id,
)
record_activity(
self.session,
event_type="agent.wakeup.sent",
message=f"Wakeup message sent to {agent.name}.",
agent_id=agent.id,
message=f"Wakeup message sent to {provisioned.name}.",
agent_id=provisioned.id,
board_id=provisioned.board_id,
)
await self.session.commit()
self.logger.info(
"agent.provision.success action=%s agent_id=%s",
action,
agent.id,
provisioned.id,
)
except OpenClawGatewayError as exc:
except HTTPException as exc:
self.record_instruction_failure(
self.session,
agent,
str(exc),
str(exc.detail),
action,
)
await self.session.commit()
self.logger.error(
"agent.provision.gateway_error action=%s agent_id=%s error=%s",
action,
agent.id,
str(exc),
)
if exc.status_code == status.HTTP_502_BAD_GATEWAY:
self.logger.error(
"agent.provision.gateway_error action=%s agent_id=%s error=%s",
action,
agent.id,
str(exc.detail),
)
else:
self.logger.critical(
"agent.provision.runtime_error action=%s agent_id=%s error=%s",
action,
agent.id,
str(exc.detail),
)
if raise_gateway_errors:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway {action} failed: {exc}",
) from exc
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
self.record_instruction_failure(
self.session,
agent,
str(exc),
action,
)
await self.session.commit()
self.logger.critical(
"agent.provision.runtime_error action=%s agent_id=%s error=%s",
action,
agent.id,
str(exc),
)
if raise_gateway_errors:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unexpected error {action}ing agent provisioning.",
) from exc
raise
async def provision_new_agent(
self,
@@ -1310,7 +1316,6 @@ class AgentLifecycleService(OpenClawDBService):
@staticmethod
def mark_agent_update_pending(agent: Agent) -> str:
raw_token = mint_agent_token(agent)
mark_provision_requested(agent, action="update", status="updating")
return raw_token
async def provision_updated_agent(
@@ -1385,7 +1390,6 @@ class AgentLifecycleService(OpenClawDBService):
return
raw_token = mint_agent_token(agent)
mark_provision_requested(agent, action="provision", status="provisioning")
await self.add_commit_refresh(agent)
board = await self.require_board(
str(agent.board_id) if agent.board_id else None,
@@ -1431,6 +1435,10 @@ class AgentLifecycleService(OpenClawDBService):
elif agent.status == "provisioning":
agent.status = "online"
agent.last_seen_at = utcnow()
# Successful check-in ends the current wake escalation cycle.
agent.wake_attempts = 0
agent.checkin_deadline_at = None
agent.last_provision_error = None
agent.updated_at = utcnow()
self.record_heartbeat(self.session, agent)
self.session.add(agent)
@@ -1814,6 +1822,7 @@ class AgentLifecycleService(OpenClawDBService):
event_type="agent.delete.direct",
message=f"Deleted agent {agent.name}.",
agent_id=None,
board_id=agent.board_id,
)
now = utcnow()
await crud.update_where(
@@ -11,6 +11,7 @@ from fastapi import HTTPException, status
from app.core.logging import TRACE_LEVEL
from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.gateway_api import (
GatewayResolveQuery,
GatewaySessionHistoryResponse,
@@ -20,7 +21,8 @@ from app.schemas.gateway_api import (
GatewaysStatusResponse,
)
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.error_messages import normalize_gateway_error_message
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import (
@@ -64,11 +66,15 @@ class GatewaySessionService(OpenClawDBService):
board_id: str | None,
gateway_url: str | None,
gateway_token: str | None,
gateway_disable_device_pairing: bool | None = None,
gateway_allow_insecure_tls: bool | None = None,
) -> GatewayResolveQuery:
return GatewayResolveQuery(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
gateway_disable_device_pairing=gateway_disable_device_pairing,
gateway_allow_insecure_tls=gateway_allow_insecure_tls,
)
@staticmethod
@@ -90,6 +96,7 @@ class GatewaySessionService(OpenClawDBService):
params: GatewayResolveQuery,
*,
user: User | None = None,
organization_id: UUID | None = None,
) -> tuple[Board | None, GatewayClientConfig, str | None]:
self.logger.log(
TRACE_LEVEL,
@@ -104,11 +111,34 @@ class GatewaySessionService(OpenClawDBService):
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="board_id or gateway_url is required",
)
token = (params.gateway_token or "").strip() or None
gateway: Gateway | None = None
can_query_saved_gateway = organization_id is not None and hasattr(self.session, "exec")
if can_query_saved_gateway and (
params.gateway_allow_insecure_tls is None
or params.gateway_disable_device_pairing is None
):
gateway_query = Gateway.objects.filter_by(url=raw_url)
if organization_id is not None:
gateway_query = gateway_query.filter_by(organization_id=organization_id)
gateway = await gateway_query.first(self.session)
allow_insecure_tls = (
params.gateway_allow_insecure_tls
if params.gateway_allow_insecure_tls is not None
else (gateway.allow_insecure_tls if gateway is not None else False)
)
disable_device_pairing = (
params.gateway_disable_device_pairing
if params.gateway_disable_device_pairing is not None
else (gateway.disable_device_pairing if gateway is not None else False)
)
return (
None,
GatewayClientConfig(
url=raw_url,
token=(params.gateway_token or "").strip() or None,
token=token,
allow_insecure_tls=allow_insecure_tls,
disable_device_pairing=disable_device_pairing,
),
None,
)
@@ -187,15 +217,19 @@ class GatewaySessionService(OpenClawDBService):
organization_id: UUID,
user: User | None,
) -> GatewaysStatusResponse:
board, config, main_session = await self.resolve_gateway(params, user=user)
board, config, main_session = await self.resolve_gateway(
params,
user=user,
organization_id=organization_id,
)
self._require_same_org(board, organization_id)
try:
compatibility = await check_gateway_runtime_compatibility(config)
compatibility = await check_gateway_version_compatibility(config)
except OpenClawGatewayError as exc:
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
error=str(exc),
error=normalize_gateway_error_message(str(exc)),
)
if not compatibility.compatible:
return GatewaysStatusResponse(
@@ -234,7 +268,7 @@ class GatewaySessionService(OpenClawDBService):
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
error=str(exc),
error=normalize_gateway_error_message(str(exc)),
)
async def get_sessions(
+26
View File
@@ -150,6 +150,32 @@ def enqueue_task(
return False
def enqueue_task_with_delay(
task: QueuedTask,
queue_name: str,
*,
delay_seconds: float,
redis_url: str | None = None,
) -> bool:
"""Enqueue a task immediately or schedule it for delayed delivery."""
delay = max(0.0, float(delay_seconds))
if delay == 0:
return enqueue_task(task, queue_name, redis_url=redis_url)
try:
return _schedule_for_later(task, queue_name, delay, redis_url=redis_url)
except Exception as exc:
logger.warning(
"rq.queue.schedule_failed",
extra={
"task_type": task.task_type,
"queue_name": queue_name,
"delay_seconds": delay,
"error": str(exc),
},
)
return False
def _coerce_datetime(raw: object | None) -> datetime:
if raw is None:
return datetime.now(UTC)
+16 -1
View File
@@ -9,6 +9,11 @@ from dataclasses import dataclass
from app.core.config import settings
from app.core.logging import get_logger
from app.services.openclaw.lifecycle_queue import TASK_TYPE as LIFECYCLE_RECONCILE_TASK_TYPE
from app.services.openclaw.lifecycle_queue import (
requeue_lifecycle_queue_task,
)
from app.services.openclaw.lifecycle_reconcile import process_lifecycle_queue_task
from app.services.queue import QueuedTask, dequeue_task
from app.services.webhooks.dispatch import (
process_webhook_queue_task,
@@ -17,6 +22,7 @@ from app.services.webhooks.dispatch import (
from app.services.webhooks.queue import TASK_TYPE as WEBHOOK_TASK_TYPE
logger = get_logger(__name__)
_WORKER_BLOCK_TIMEOUT_SECONDS = 5.0
@dataclass(frozen=True)
@@ -27,6 +33,14 @@ class _TaskHandler:
_TASK_HANDLERS: dict[str, _TaskHandler] = {
LIFECYCLE_RECONCILE_TASK_TYPE: _TaskHandler(
handler=process_lifecycle_queue_task,
attempts_to_delay=lambda attempts: min(
settings.rq_dispatch_retry_base_seconds * (2 ** max(0, attempts)),
settings.rq_dispatch_retry_max_seconds,
),
requeue=lambda task, delay: requeue_lifecycle_queue_task(task, delay_seconds=delay),
),
WEBHOOK_TASK_TYPE: _TaskHandler(
handler=process_webhook_queue_task,
attempts_to_delay=lambda attempts: min(
@@ -115,7 +129,8 @@ async def _run_worker_loop() -> None:
try:
await flush_queue(
block=True,
block_timeout=0,
# Keep a finite timeout so scheduled tasks are periodically drained.
block_timeout=_WORKER_BLOCK_TIMEOUT_SECONDS,
)
except Exception:
logger.exception(
@@ -0,0 +1,66 @@
"""add board_id to activity_events
Revision ID: a9b1c2d3e4f7
Revises: f1b2c3d4e5a6
Create Date: 2026-03-04 18:20:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a9b1c2d3e4f7"
down_revision = "f1b2c3d4e5a6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("activity_events", sa.Column("board_id", sa.Uuid(), nullable=True))
op.execute(
"""
UPDATE activity_events AS ae
SET board_id = t.board_id
FROM tasks AS t
WHERE ae.task_id = t.id
AND ae.board_id IS NULL
"""
)
op.execute(
"""
UPDATE activity_events AS ae
SET board_id = a.board_id
FROM agents AS a
WHERE ae.agent_id = a.id
AND ae.board_id IS NULL
AND a.board_id IS NOT NULL
"""
)
op.create_foreign_key(
"fk_activity_events_board_id_boards",
"activity_events",
"boards",
["board_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_activity_events_board_id"),
"activity_events",
["board_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_activity_events_board_id"), table_name="activity_events")
op.drop_constraint(
"fk_activity_events_board_id_boards",
"activity_events",
type_="foreignkey",
)
op.drop_column("activity_events", "board_id")
@@ -0,0 +1,38 @@
"""Add allow_insecure_tls field to gateways.
Revision ID: b497b348ebb4
Revises: c5d1a2b3e4f6
Create Date: 2026-02-22 20:06:54.417968
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b497b348ebb4"
down_revision = "c5d1a2b3e4f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add gateways.allow_insecure_tls column with default False."""
op.add_column(
"gateways",
sa.Column(
"allow_insecure_tls",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.alter_column("gateways", "allow_insecure_tls", server_default=None)
def downgrade() -> None:
"""Remove gateways.allow_insecure_tls column."""
op.drop_column("gateways", "allow_insecure_tls")
@@ -0,0 +1,37 @@
"""Add disable_device_pairing setting to gateways.
Revision ID: c5d1a2b3e4f6
Revises: b7a1d9c3e4f5
Create Date: 2026-02-22 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c5d1a2b3e4f6"
down_revision = "b7a1d9c3e4f5"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add gateway toggle to bypass device pairing handshake."""
op.add_column(
"gateways",
sa.Column(
"disable_device_pairing",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.alter_column("gateways", "disable_device_pairing", server_default=None)
def downgrade() -> None:
"""Remove gateway toggle to bypass device pairing handshake."""
op.drop_column("gateways", "disable_device_pairing")
@@ -0,0 +1,45 @@
"""Add agent lifecycle metadata columns.
Revision ID: e3a1b2c4d5f6
Revises: b497b348ebb4
Create Date: 2026-02-24 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "e3a1b2c4d5f6"
down_revision = "b497b348ebb4"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add lifecycle generation, wake tracking, and failure metadata."""
op.add_column(
"agents",
sa.Column("lifecycle_generation", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"agents",
sa.Column("wake_attempts", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column("agents", sa.Column("last_wake_sent_at", sa.DateTime(), nullable=True))
op.add_column("agents", sa.Column("checkin_deadline_at", sa.DateTime(), nullable=True))
op.add_column("agents", sa.Column("last_provision_error", sa.Text(), nullable=True))
op.alter_column("agents", "lifecycle_generation", server_default=None)
op.alter_column("agents", "wake_attempts", server_default=None)
def downgrade() -> None:
"""Remove lifecycle generation, wake tracking, and failure metadata."""
op.drop_column("agents", "last_provision_error")
op.drop_column("agents", "checkin_deadline_at")
op.drop_column("agents", "last_wake_sent_at")
op.drop_column("agents", "wake_attempts")
op.drop_column("agents", "lifecycle_generation")
@@ -0,0 +1,43 @@
"""add comment-required-for-review board rule
Revision ID: f1b2c3d4e5a6
Revises: e3a1b2c4d5f6
Create Date: 2026-02-25 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f1b2c3d4e5a6"
down_revision = "e3a1b2c4d5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
board_columns = {column["name"] for column in inspector.get_columns("boards")}
if "comment_required_for_review" not in board_columns:
op.add_column(
"boards",
sa.Column(
"comment_required_for_review",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
board_columns = {column["name"] for column in inspector.get_columns("boards")}
if "comment_required_for_review" in board_columns:
op.drop_column("boards", "comment_required_for_review")
+2 -1
View File
@@ -14,7 +14,7 @@ requires-python = ">=3.12"
dependencies = [
"alembic==1.18.3",
"clerk-backend-api==4.2.0",
"fastapi==0.128.6",
"fastapi==0.131.0",
"fastapi-pagination==0.15.10",
"jinja2==3.1.6",
"psycopg[binary]==3.3.2",
@@ -27,6 +27,7 @@ dependencies = [
"websockets==16.0",
"redis==6.3.0",
"rq==2.6.0",
"cryptography==45.0.7",
]
[project.optional-dependencies]
+9 -2
View File
@@ -35,12 +35,19 @@ curl -fsS "{{ base_url }}/healthz" >/dev/null
5) Ensure today's daily file exists: `memory/YYYY-MM-DD.md`.
{% if is_lead %}
6) Initialize current delivery status in `MEMORY.md`:
6) Immediately check in to Mission Control (do this before any task orchestration):
```bash
curl -s -X POST "{{ base_url }}/api/v1/agent/heartbeat" \
-H "X-Agent-Token: {{ auth_token }}"
```
7) Initialize current delivery status in `MEMORY.md`:
- set objective if missing
- set state to `Working` (or `Waiting` if external dependency exists)
- set one concrete next step
7) Add one line to `MEMORY.md` noting bootstrap completion date.
8) Add one line to `MEMORY.md` noting bootstrap completion date.
{% else %}
6) If any fields are blank, leave them blank. Do not invent values.
+11 -1
View File
@@ -35,12 +35,20 @@ jq -r '
## Schedule
- If a heartbeat schedule is configured, send a lightweight check-in only.
- On first cycle after wake/bootstrap, run heartbeat check-in immediately (do not wait for cadence).
- Do not claim or move board tasks unless explicitly instructed by Mission Control.
- If you have any pending `LEAD REQUEST: ASK USER` messages in OpenClaw chat, handle them promptly (see AGENTS.md).
## Heartbeat checklist
1) Check in:
1) Check in immediately:
- Use the `agent-main` heartbeat endpoint (`POST /api/v1/agent/heartbeat`).
- Startup check-in example:
```bash
curl -s -X POST "{{ base_url }}/api/v1/agent/heartbeat" \
-H "X-Agent-Token: {{ auth_token }}"
```
- If check-in fails due to 5xx/network, stop and retry next heartbeat.
- During that failure window, do **not** write memory updates (`MEMORY.md`, daily memory files).
@@ -117,6 +125,7 @@ jq -r '
## Schedule
- Heartbeat cadence is controlled by gateway heartbeat config.
- On first cycle after wake/bootstrap, run heartbeat check-in immediately (do not wait for cadence).
- Keep cadence conservative unless there is a clear latency need.
## Non-Negotiable Rules
@@ -153,6 +162,7 @@ Before execution:
### Board Rule Snapshot
- `require_review_before_done`: `{{ board_rule_require_review_before_done }}`
- `require_approval_for_done`: `{{ board_rule_require_approval_for_done }}`
- `comment_required_for_review`: `{{ board_rule_comment_required_for_review }}`
- `block_status_changes_with_pending_approval`: `{{ board_rule_block_status_changes_with_pending_approval }}`
- `only_lead_can_change_status`: `{{ board_rule_only_lead_can_change_status }}`
- `max_agents`: `{{ board_rule_max_agents }}`
+1
View File
@@ -133,6 +133,7 @@ This avoids relying on startup hooks to populate `api/openapi.json`.
- `workspace_path`
- `board_rule_require_approval_for_done`
- `board_rule_require_review_before_done`
- `board_rule_comment_required_for_review`
- `board_rule_block_status_changes_with_pending_approval`
- `board_rule_only_lead_can_change_status`
- `board_rule_max_agents`
+1
View File
@@ -13,3 +13,4 @@ if str(ROOT) not in sys.path:
# defaults during import-time settings initialization, regardless of shell env.
os.environ["AUTH_MODE"] = "local"
os.environ["LOCAL_AUTH_TOKEN"] = "test-local-token-0123456789-0123456789-0123456789x"
os.environ["BASE_URL"] = "http://localhost:8000"
+86 -1
View File
@@ -5,7 +5,7 @@ from uuid import uuid4
import pytest
from app.api.activity import _coerce_task_comment_rows
from app.api.activity import _build_activity_route, _coerce_activity_rows, _coerce_task_comment_rows
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
@@ -34,6 +34,25 @@ class _FakeSqlRow4:
raise IndexError(index)
@dataclass
class _FakeSqlRow3:
first: object
second: object
third: object
def __len__(self) -> int:
return 3
def __getitem__(self, index: int) -> object:
if index == 0:
return self.first
if index == 1:
return self.second
if index == 2:
return self.third
raise IndexError(index)
def _make_event() -> ActivityEvent:
return ActivityEvent(event_type="task.comment", message="hello")
@@ -87,3 +106,69 @@ def test_coerce_task_comment_rows_rejects_invalid_values():
match="Expected \\(ActivityEvent, Task, Board, Agent \\| None\\) rows",
):
_coerce_task_comment_rows([(uuid4(), task, board, None)])
def test_coerce_activity_rows_accepts_plain_tuple():
board_id = uuid4()
event = _make_event()
rows = _coerce_activity_rows([(event, board_id, None)])
assert rows == [(event, board_id, None)]
def test_coerce_activity_rows_accepts_row_like_values():
board_id = uuid4()
event = _make_event()
row = _FakeSqlRow3(event, board_id, None)
rows = _coerce_activity_rows([row])
assert rows == [(event, board_id, None)]
def test_coerce_activity_rows_rejects_invalid_values():
event = _make_event()
with pytest.raises(
TypeError,
match="Expected \\(ActivityEvent, event_board_id, task_board_id\\) rows",
):
_coerce_activity_rows([(event, "bad", None)])
def test_build_activity_route_board_comment():
board_id = uuid4()
task_id = uuid4()
event = ActivityEvent(
event_type="task.comment",
task_id=task_id,
message="hello",
)
route_name, route_params = _build_activity_route(event=event, board_id=board_id)
assert route_name == "board"
assert route_params == {
"boardId": str(board_id),
"taskId": str(task_id),
"commentId": str(event.id),
}
def test_build_activity_route_board_approvals():
board_id = uuid4()
event = ActivityEvent(
event_type="approval.lead_notified",
message="hello",
)
route_name, route_params = _build_activity_route(event=event, board_id=board_id)
assert route_name == "board.approvals"
assert route_params == {"boardId": str(board_id)}
def test_build_activity_route_global_fallback():
event = ActivityEvent(
event_type="gateway.main.lead_broadcast.sent",
message="hello",
)
route_name, route_params = _build_activity_route(event=event, board_id=None)
assert route_name == "activity"
assert route_params["eventId"] == str(event.id)
assert route_params["eventType"] == event.event_type
assert route_params["createdAt"] == event.created_at.isoformat()
@@ -51,6 +51,8 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
@pytest.mark.asyncio
@@ -43,6 +43,8 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
@pytest.mark.asyncio
@@ -119,6 +119,8 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
@pytest.mark.asyncio
@@ -229,6 +231,8 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -296,6 +300,8 @@ async def test_set_agent_files_update_preserves_user_md_even_when_size_zero():
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -360,6 +366,8 @@ async def test_set_agent_files_update_preserves_nonmissing_user_md():
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -424,6 +432,8 @@ async def test_set_agent_files_update_overwrite_writes_preserved_user_md():
url: str
token: str | None
workspace_root: str
allow_insecure_tls: bool = False
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -520,6 +530,89 @@ async def test_control_plane_upsert_agent_handles_already_exists(monkeypatch):
assert calls[1][0] == "agents.update"
@pytest.mark.asyncio
async def test_control_plane_upsert_agent_retries_update_after_create_race(monkeypatch):
calls: list[tuple[str, dict[str, object] | None]] = []
sleeps: list[float] = []
update_attempts = 0
async def _fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
async def _fake_openclaw_call(method, params=None, config=None):
nonlocal update_attempts
_ = config
calls.append((method, params))
if method == "agents.create":
return {"ok": True}
if method == "agents.update":
update_attempts += 1
if update_attempts < 3:
raise agent_provisioning.OpenClawGatewayError('agent "board-agent-a" not found')
return {"ok": True}
if method == "config.get":
return {"hash": None, "config": {"agents": {"list": []}}}
if method == "config.patch":
return {"ok": True}
raise AssertionError(f"Unexpected method: {method}")
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
monkeypatch.setattr(agent_provisioning.asyncio, "sleep", _fake_sleep)
cp = agent_provisioning.OpenClawGatewayControlPlane(
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
)
await cp.upsert_agent(
agent_provisioning.GatewayAgentRegistration(
agent_id="board-agent-a",
name="Board Agent A",
workspace_path="/tmp/workspace-board-agent-a",
heartbeat={"every": "10m", "target": "last", "includeReasoning": False},
),
)
update_calls = [method for method, _ in calls if method == "agents.update"]
assert len(update_calls) == 3
assert sleeps == [0.75, 0.5, 1.0]
@pytest.mark.asyncio
async def test_control_plane_upsert_agent_missing_after_already_exists_fails_fast(monkeypatch):
calls: list[tuple[str, dict[str, object] | None]] = []
sleeps: list[float] = []
async def _fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
async def _fake_openclaw_call(method, params=None, config=None):
_ = config
calls.append((method, params))
if method == "agents.create":
raise agent_provisioning.OpenClawGatewayError("already exists")
if method == "agents.update":
raise agent_provisioning.OpenClawGatewayError('agent "board-agent-a" not found')
raise AssertionError(f"Unexpected method: {method}")
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
monkeypatch.setattr(agent_provisioning.asyncio, "sleep", _fake_sleep)
cp = agent_provisioning.OpenClawGatewayControlPlane(
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
)
with pytest.raises(agent_provisioning.OpenClawGatewayError):
await cp.upsert_agent(
agent_provisioning.GatewayAgentRegistration(
agent_id="board-agent-a",
name="Board Agent A",
workspace_path="/tmp/workspace-board-agent-a",
heartbeat={"every": "10m", "target": "last", "includeReasoning": False},
),
)
update_calls = [method for method, _ in calls if method == "agents.update"]
assert len(update_calls) == 1
assert sleeps == []
def test_is_missing_agent_error_matches_gateway_agent_not_found() -> None:
assert agent_provisioning._is_missing_agent_error(
agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found'),
@@ -0,0 +1,287 @@
# ruff: noqa: INP001
from __future__ import annotations
import json
from uuid import UUID, uuid4
import pytest
from fastapi import APIRouter, Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.agent import router as agent_router
from app.api.deps import get_board_or_404
from app.core.agent_tokens import hash_agent_token
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organizations import Organization
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.connect() as conn, conn.begin():
await conn.run_sync(SQLModel.metadata.create_all)
return engine
def _build_test_app(session_maker: async_sessionmaker[AsyncSession]) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(agent_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
async def _override_get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
) -> Board:
board = await Board.objects.by_id(UUID(board_id)).first(session)
if board is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return board
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[get_board_or_404] = _override_get_board_or_404
return app
async def _seed_payload(
session: AsyncSession,
*,
payload_value: dict[str, object] | list[object] | str | int | float | bool | None = None,
) -> tuple[str, Board, BoardWebhook, BoardWebhookPayload]:
token = "test-agent-token-" + uuid4().hex
token_hash = hash_agent_token(token)
organization_id = uuid4()
gateway_id = uuid4()
board_id = uuid4()
webhook_id = uuid4()
agent_id = uuid4()
payload_id = uuid4()
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
session.add(
Gateway(
id=gateway_id,
organization_id=organization_id,
name="gateway",
url="https://gateway.example.local",
workspace_root="/tmp/workspace",
),
)
board = Board(
id=board_id,
organization_id=organization_id,
gateway_id=gateway_id,
name="Board",
slug="board",
)
session.add(board)
session.add(
Agent(
id=agent_id,
board_id=board_id,
gateway_id=gateway_id,
name="Lead Agent",
status="online",
is_board_lead=True,
openclaw_session_id="agent:lead:session",
agent_token_hash=token_hash,
),
)
webhook = BoardWebhook(
id=webhook_id,
board_id=board_id,
description="Triage payload",
enabled=True,
)
session.add(webhook)
payload = BoardWebhookPayload(
id=payload_id,
board_id=board_id,
webhook_id=webhook_id,
payload=payload_value or {"event": "push", "ref": "refs/heads/master"},
headers={"x-github-event": "push"},
content_type="application/json",
source_ip="127.0.0.1",
)
session.add(payload)
await session.commit()
return token, board, webhook, payload
@pytest.mark.asyncio
async def test_agent_can_fetch_webhook_payload() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = _build_test_app(session_maker)
async with session_maker() as session:
token, board, webhook, payload = await _seed_payload(session)
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}",
headers={"X-Agent-Token": token},
)
assert response.status_code == 200
body = response.json()
assert body["id"] == str(payload.id)
assert body["board_id"] == str(board.id)
assert body["webhook_id"] == str(webhook.id)
assert body["payload"] == {"event": "push", "ref": "refs/heads/master"}
assert body["headers"]["x-github-event"] == "push"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_agent_payload_read_rejects_invalid_token() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = _build_test_app(session_maker)
async with session_maker() as session:
_token, board, webhook, payload = await _seed_payload(session)
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}",
headers={"X-Agent-Token": "invalid"},
)
assert response.status_code == 401
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_agent_payload_read_truncates_json_preview_with_ellipsis() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = _build_test_app(session_maker)
async with session_maker() as session:
payload_value: dict[str, object] = {"event": "push", "ref": "refs/heads/master"}
token, board, webhook, payload = await _seed_payload(session, payload_value=payload_value)
max_chars = 12
raw = json.dumps(payload_value, ensure_ascii=True)
expected_preview = f"{raw[: max_chars - 3]}..."
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}",
headers={"X-Agent-Token": token},
params={"max_chars": max_chars},
)
assert response.status_code == 200
body = response.json()
assert body["payload"] == expected_preview
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_agent_payload_read_truncates_string_preview_without_json_quoting() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = _build_test_app(session_maker)
async with session_maker() as session:
token, board, webhook, payload = await _seed_payload(session, payload_value="abcdef")
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}",
headers={"X-Agent-Token": token},
params={"max_chars": 4},
)
assert response.status_code == 200
body = response.json()
assert body["payload"] == "a..."
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_agent_payload_read_rejects_cross_board_access() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = _build_test_app(session_maker)
async with session_maker() as session:
token, board, webhook, payload = await _seed_payload(session)
# Second board + payload that should be inaccessible to the first board agent.
organization_id = uuid4()
gateway_id = uuid4()
other_board = Board(
id=uuid4(),
organization_id=organization_id,
gateway_id=gateway_id,
name="Other",
slug="other",
)
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
session.add(
Gateway(
id=gateway_id,
organization_id=organization_id,
name="gateway",
url="https://gateway.example.local",
workspace_root="/tmp/workspace",
),
)
session.add(other_board)
other_webhook = BoardWebhook(
id=uuid4(),
board_id=other_board.id,
description="Other webhook",
enabled=True,
)
session.add(other_webhook)
other_payload = BoardWebhookPayload(
id=uuid4(),
board_id=other_board.id,
webhook_id=other_webhook.id,
payload={"event": "push"},
)
session.add(other_payload)
await session.commit()
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
f"/api/v1/agent/boards/{other_board.id}/webhooks/{other_webhook.id}/payloads/{other_payload.id}",
headers={"X-Agent-Token": token},
)
assert response.status_code == 403
finally:
await engine.dispose()
@@ -69,11 +69,15 @@ async def test_update_board_notifies_agents_when_added_to_group(
async def _fake_notify(**_kwargs: Any) -> None:
calls["notify"] += 1
async def _fake_lead_notify(**_kwargs: Any) -> None:
return None
async def _fake_get_by_id(*_args: Any, **_kwargs: Any) -> BoardGroup:
return group
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_notify)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id)
updated = await boards.update_board(
@@ -108,12 +112,16 @@ async def test_update_board_notifies_agents_when_removed_from_group(
async def _fake_leave(**_kwargs: Any) -> None:
calls["leave"] += 1
async def _fake_lead_notify(**_kwargs: Any) -> None:
return None
async def _fake_get_by_id(*_args: Any, **_kwargs: Any) -> BoardGroup:
return group
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_join)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_leave)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id)
updated = await boards.update_board(
@@ -151,6 +159,9 @@ async def test_update_board_notifies_agents_when_moved_between_groups(
async def _fake_leave(**_kwargs: Any) -> None:
calls["leave"] += 1
async def _fake_lead_notify(**_kwargs: Any) -> None:
return None
async def _fake_get_by_id(_session: Any, _model: Any, obj_id: UUID) -> BoardGroup | None:
if obj_id == old_group_id:
return old_group
@@ -161,6 +172,7 @@ async def test_update_board_notifies_agents_when_moved_between_groups(
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_join)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_leave)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id)
updated = await boards.update_board(
@@ -192,9 +204,13 @@ async def test_update_board_does_not_notify_when_group_unchanged(
async def _fake_notify(**_kwargs: Any) -> None:
calls["notify"] += 1
async def _fake_lead_notify(**_kwargs: Any) -> None:
return None
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_notify)
monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_notify)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
updated = await boards.update_board(
payload=payload,
@@ -206,6 +222,66 @@ async def test_update_board_does_not_notify_when_group_unchanged(
assert calls["notify"] == 0
@pytest.mark.asyncio
async def test_update_board_notifies_lead_when_fields_change(
monkeypatch: pytest.MonkeyPatch,
) -> None:
board = _board(board_group_id=None)
session = _FakeSession()
payload = BoardUpdate(name="Platform X")
calls: dict[str, object] = {"count": 0, "changes": {}}
async def _fake_apply_board_update(**kwargs: Any) -> Board:
target: Board = kwargs["board"]
target.name = "Platform X"
return target
async def _fake_lead_notify(**kwargs: Any) -> None:
calls["count"] = int(calls["count"]) + 1
calls["changes"] = kwargs["changed_fields"]
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
updated = await boards.update_board(
payload=payload,
session=session, # type: ignore[arg-type]
board=board,
)
assert updated.name == "Platform X"
assert calls["count"] == 1
assert calls["changes"] == {"name": ("Platform", "Platform X")}
@pytest.mark.asyncio
async def test_update_board_skips_lead_notify_when_no_effective_change(
monkeypatch: pytest.MonkeyPatch,
) -> None:
board = _board(board_group_id=None)
session = _FakeSession()
payload = BoardUpdate(name="Platform")
calls = {"lead_notify": 0}
async def _fake_apply_board_update(**kwargs: Any) -> Board:
return kwargs["board"]
async def _fake_lead_notify(**_kwargs: Any) -> None:
calls["lead_notify"] += 1
monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update)
monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify)
updated = await boards.update_board(
payload=payload,
session=session, # type: ignore[arg-type]
board=board,
)
assert updated.name == "Platform"
assert calls["lead_notify"] == 0
@pytest.mark.asyncio
async def test_notify_agents_on_board_group_addition_fanout_and_records_results(
monkeypatch: pytest.MonkeyPatch,
+3
View File
@@ -86,6 +86,7 @@ def test_board_rule_toggles_have_expected_defaults() -> None:
)
assert created.require_approval_for_done is True
assert created.require_review_before_done is False
assert created.comment_required_for_review is False
assert created.block_status_changes_with_pending_approval is False
assert created.only_lead_can_change_status is False
assert created.max_agents == 1
@@ -93,12 +94,14 @@ def test_board_rule_toggles_have_expected_defaults() -> None:
updated = BoardUpdate(
require_approval_for_done=False,
require_review_before_done=True,
comment_required_for_review=True,
block_status_changes_with_pending_approval=True,
only_lead_can_change_status=True,
max_agents=3,
)
assert updated.require_approval_for_done is False
assert updated.require_review_before_done is True
assert updated.comment_required_for_review is True
assert updated.block_status_changes_with_pending_approval is True
assert updated.only_lead_can_change_status is True
assert updated.max_agents == 3
+1
View File
@@ -62,6 +62,7 @@ async def test_delete_board_cleans_org_board_access_rows() -> None:
)
deleted_table_names = [statement.table.name for statement in session.executed]
assert "activity_events" in deleted_table_names
assert "organization_board_access" in deleted_table_names
assert "organization_invite_board_access" in deleted_table_names
assert "board_task_custom_fields" in deleted_table_names
+67
View File
@@ -9,6 +9,8 @@ from pydantic import ValidationError
from app.core.auth_mode import AuthMode
from app.core.config import Settings
BASE_URL = "http://localhost:8000"
def test_local_mode_requires_non_empty_token() -> None:
with pytest.raises(
@@ -19,6 +21,7 @@ def test_local_mode_requires_non_empty_token() -> None:
_env_file=None,
auth_mode=AuthMode.LOCAL,
local_auth_token="",
base_url=BASE_URL,
)
@@ -31,6 +34,7 @@ def test_local_mode_requires_minimum_length() -> None:
_env_file=None,
auth_mode=AuthMode.LOCAL,
local_auth_token="x" * 49,
base_url=BASE_URL,
)
@@ -43,6 +47,7 @@ def test_local_mode_rejects_placeholder_token() -> None:
_env_file=None,
auth_mode=AuthMode.LOCAL,
local_auth_token="change-me",
base_url=BASE_URL,
)
@@ -52,6 +57,7 @@ def test_local_mode_accepts_real_token() -> None:
_env_file=None,
auth_mode=AuthMode.LOCAL,
local_auth_token=token,
base_url=BASE_URL,
)
assert settings.auth_mode == AuthMode.LOCAL
@@ -67,4 +73,65 @@ def test_clerk_mode_requires_secret_key() -> None:
_env_file=None,
auth_mode=AuthMode.CLERK,
clerk_secret_key="",
base_url=BASE_URL,
)
def test_base_url_required() -> None:
with pytest.raises(
ValidationError,
match="BASE_URL must be set and non-empty",
):
Settings(
_env_file=None,
auth_mode=AuthMode.CLERK,
clerk_secret_key="sk_test",
base_url=" ",
)
def test_base_url_field_is_required(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BASE_URL", raising=False)
with pytest.raises(ValidationError) as exc_info:
Settings(
_env_file=None,
auth_mode=AuthMode.CLERK,
clerk_secret_key="sk_test",
)
text = str(exc_info.value)
assert "base_url" in text
assert "Field required" in text
@pytest.mark.parametrize(
"base_url",
[
"localhost:8000",
"ws://localhost:8000",
],
)
def test_base_url_requires_absolute_http_url(base_url: str) -> None:
with pytest.raises(
ValidationError,
match="BASE_URL must be an absolute http\\(s\\) URL",
):
Settings(
_env_file=None,
auth_mode=AuthMode.CLERK,
clerk_secret_key="sk_test",
base_url=base_url,
)
def test_base_url_is_normalized_without_trailing_slash() -> None:
token = "a" * 50
settings = Settings(
_env_file=None,
auth_mode=AuthMode.LOCAL,
local_auth_token=token,
base_url="http://localhost:8000/ ",
)
assert settings.base_url == BASE_URL
@@ -0,0 +1,67 @@
from __future__ import annotations
import base64
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from app.services.openclaw.device_identity import (
build_device_auth_payload,
load_or_create_device_identity,
sign_device_payload,
)
def _base64url_decode(value: str) -> bytes:
padding = "=" * ((4 - len(value) % 4) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}")
def test_load_or_create_device_identity_persists_same_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
identity_path = tmp_path / "identity" / "device.json"
monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
first = load_or_create_device_identity()
second = load_or_create_device_identity()
assert identity_path.exists()
assert first.device_id == second.device_id
assert first.public_key_pem.strip() == second.public_key_pem.strip()
assert first.private_key_pem.strip() == second.private_key_pem.strip()
def test_build_device_auth_payload_uses_nonce_for_v2() -> None:
payload = build_device_auth_payload(
device_id="dev",
client_id="gateway-client",
client_mode="backend",
role="operator",
scopes=["operator.read", "operator.admin"],
signed_at_ms=123,
token="token",
nonce="nonce-xyz",
)
assert payload == (
"v2|dev|gateway-client|backend|operator|operator.read,operator.admin|123|token|nonce-xyz"
)
def test_sign_device_payload_produces_valid_ed25519_signature(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
identity_path = tmp_path / "identity" / "device.json"
monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
identity = load_or_create_device_identity()
payload = "v1|device|client|backend|operator|operator.read|1|token"
signature = sign_device_payload(identity.private_key_pem, payload)
loaded = serialization.load_pem_public_key(identity.public_key_pem.encode("utf-8"))
assert isinstance(loaded, Ed25519PublicKey)
loaded.verify(_base64url_decode(signature), payload.encode("utf-8"))
+182
View File
@@ -0,0 +1,182 @@
# ruff: noqa: S101
from __future__ import annotations
from uuid import uuid4
import pytest
import app.services.openclaw.session_service as session_service
from app.models.gateways import Gateway
from app.schemas.gateway_api import GatewayResolveQuery
from app.services.openclaw.gateway_resolver import (
gateway_client_config,
optional_gateway_client_config,
)
from app.services.openclaw.session_service import GatewaySessionService
def _gateway(
*,
disable_device_pairing: bool,
allow_insecure_tls: bool = False,
url: str = "ws://gateway.example:18789/ws",
token: str | None = " secret-token ",
) -> Gateway:
return Gateway(
id=uuid4(),
organization_id=uuid4(),
name="Primary gateway",
url=url,
token=token,
workspace_root="~/.openclaw",
disable_device_pairing=disable_device_pairing,
allow_insecure_tls=allow_insecure_tls,
)
def test_gateway_client_config_maps_disable_device_pairing() -> None:
config = gateway_client_config(_gateway(disable_device_pairing=True))
assert config.url == "ws://gateway.example:18789/ws"
assert config.token == "secret-token"
assert config.disable_device_pairing is True
def test_optional_gateway_client_config_maps_disable_device_pairing() -> None:
config = optional_gateway_client_config(_gateway(disable_device_pairing=False))
assert config is not None
assert config.disable_device_pairing is False
def test_gateway_client_config_maps_allow_insecure_tls() -> None:
config = gateway_client_config(
_gateway(disable_device_pairing=False, allow_insecure_tls=True),
)
assert config.allow_insecure_tls is True
def test_optional_gateway_client_config_returns_none_for_missing_or_blank_url() -> None:
assert optional_gateway_client_config(None) is None
assert (
optional_gateway_client_config(
_gateway(disable_device_pairing=False, url=" "),
)
is None
)
def test_to_resolve_query_keeps_gateway_disable_device_pairing_value() -> None:
resolved = GatewaySessionService.to_resolve_query(
board_id=None,
gateway_url="ws://gateway.example:18789/ws",
gateway_token="secret-token",
gateway_disable_device_pairing=True,
)
assert resolved.gateway_disable_device_pairing is True
def test_to_resolve_query_keeps_gateway_allow_insecure_tls_value() -> None:
resolved = GatewaySessionService.to_resolve_query(
board_id=None,
gateway_url="wss://gateway.example:18789/ws",
gateway_token="secret-token",
gateway_allow_insecure_tls=True,
)
assert resolved.gateway_allow_insecure_tls is True
@pytest.mark.asyncio
async def test_resolve_gateway_keeps_gateway_allow_insecure_tls_for_direct_url() -> None:
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
_, config, _ = await service.resolve_gateway(
GatewayResolveQuery(
gateway_url="wss://gateway.example:18789/ws",
gateway_allow_insecure_tls=True,
),
user=None,
)
assert config.allow_insecure_tls is True
class _FakeGatewayQuery:
def __init__(self, gateway: Gateway | None) -> None:
self._gateway = gateway
self.filters: list[dict[str, object]] = []
def filter_by(self, **kwargs: object) -> _FakeGatewayQuery:
self.filters.append(kwargs)
return self
async def first(self, _session: object) -> Gateway | None:
return self._gateway
class _FakeAsyncSession:
async def exec(
self, *_args: object, **_kwargs: object
) -> None: # pragma: no cover - guard only
return None
@pytest.mark.asyncio
async def test_resolve_gateway_uses_saved_gateway_settings_when_direct_flags_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(
disable_device_pairing=True,
allow_insecure_tls=True,
url="wss://gateway.example:18789/ws",
token=" db-token ",
)
fake_query = _FakeGatewayQuery(gateway)
monkeypatch.setattr(session_service.Gateway, "objects", fake_query)
service = GatewaySessionService(session=_FakeAsyncSession()) # type: ignore[arg-type]
_, config, _ = await service.resolve_gateway(
GatewayResolveQuery(gateway_url=gateway.url),
user=None,
organization_id=gateway.organization_id,
)
assert config.token is None
assert config.allow_insecure_tls is True
assert config.disable_device_pairing is True
assert fake_query.filters == [
{"url": gateway.url},
{"organization_id": gateway.organization_id},
]
@pytest.mark.asyncio
async def test_resolve_gateway_prefers_explicit_direct_flags_over_saved_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(
disable_device_pairing=True,
allow_insecure_tls=True,
url="wss://gateway.example:18789/ws",
token="db-token",
)
fake_query = _FakeGatewayQuery(gateway)
monkeypatch.setattr(session_service.Gateway, "objects", fake_query)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
_, config, _ = await service.resolve_gateway(
GatewayResolveQuery(
gateway_url=gateway.url,
gateway_token="explicit-token",
gateway_allow_insecure_tls=False,
gateway_disable_device_pairing=False,
),
user=None,
organization_id=gateway.organization_id,
)
assert config.token == "explicit-token"
assert config.allow_insecure_tls is False
assert config.disable_device_pairing is False
@@ -1,24 +1,284 @@
from __future__ import annotations
import pytest
import app.services.openclaw.gateway_rpc as gateway_rpc
from app.services.openclaw.gateway_rpc import (
CONTROL_UI_CLIENT_ID,
CONTROL_UI_CLIENT_MODE,
DEFAULT_GATEWAY_CLIENT_ID,
DEFAULT_GATEWAY_CLIENT_MODE,
GATEWAY_OPERATOR_SCOPES,
GatewayConfig,
OpenClawGatewayError,
_build_connect_params,
_build_control_ui_origin,
openclaw_call,
)
def test_build_connect_params_sets_explicit_operator_role_and_scopes() -> None:
def test_build_connect_params_defaults_to_device_pairing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
expected_device_payload = {
"id": "device-id",
"publicKey": "public-key",
"signature": "signature",
"signedAt": 1,
}
def _fake_build_device_connect_payload(
*,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
auth_token: str | None,
connect_nonce: str | None,
) -> dict[str, object]:
captured["client_id"] = client_id
captured["client_mode"] = client_mode
captured["role"] = role
captured["scopes"] = scopes
captured["auth_token"] = auth_token
captured["connect_nonce"] = connect_nonce
return expected_device_payload
monkeypatch.setattr(
gateway_rpc,
"_build_device_connect_payload",
_fake_build_device_connect_payload,
)
params = _build_connect_params(GatewayConfig(url="ws://gateway.example/ws"))
assert params["role"] == "operator"
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert params["device"] == expected_device_payload
assert "auth" not in params
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert captured["role"] == "operator"
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert captured["auth_token"] is None
assert captured["connect_nonce"] is None
def test_build_connect_params_includes_auth_token_when_provided() -> None:
def test_build_connect_params_uses_control_ui_when_pairing_disabled() -> None:
params = _build_connect_params(
GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
GatewayConfig(
url="ws://gateway.example/ws",
token="secret-token",
disable_device_pairing=True,
),
)
assert params["auth"] == {"token": "secret-token"}
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert params["client"]["id"] == CONTROL_UI_CLIENT_ID
assert params["client"]["mode"] == CONTROL_UI_CLIENT_MODE
assert "device" not in params
def test_build_connect_params_passes_nonce_to_device_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_build_device_connect_payload(
*,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
auth_token: str | None,
connect_nonce: str | None,
) -> dict[str, object]:
captured["client_id"] = client_id
captured["client_mode"] = client_mode
captured["role"] = role
captured["scopes"] = scopes
captured["auth_token"] = auth_token
captured["connect_nonce"] = connect_nonce
return {"id": "device-id", "nonce": connect_nonce}
monkeypatch.setattr(
gateway_rpc,
"_build_device_connect_payload",
_fake_build_device_connect_payload,
)
params = _build_connect_params(
GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
connect_nonce="nonce-xyz",
)
assert params["auth"] == {"token": "secret-token"}
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert params["device"] == {"id": "device-id", "nonce": "nonce-xyz"}
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert captured["role"] == "operator"
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert captured["auth_token"] == "secret-token"
assert captured["connect_nonce"] == "nonce-xyz"
@pytest.mark.parametrize(
("gateway_url", "expected_origin"),
[
("ws://gateway.example/ws", "http://gateway.example"),
("wss://gateway.example/ws", "https://gateway.example"),
("ws://gateway.example:8080/ws", "http://gateway.example:8080"),
("wss://gateway.example:8443/ws", "https://gateway.example:8443"),
("ws://[::1]:8000/ws", "http://[::1]:8000"),
],
)
def test_build_control_ui_origin(gateway_url: str, expected_origin: str) -> None:
assert _build_control_ui_origin(gateway_url) == expected_origin
@pytest.mark.asyncio
async def test_openclaw_call_uses_single_connect_attempt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
call_count = 0
async def _fake_call_once(
method: str,
params: dict[str, object] | None,
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
nonlocal call_count
del method, params, config, gateway_url
call_count += 1
return {"ok": True}
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
payload = await openclaw_call(
"status",
config=GatewayConfig(url="ws://gateway.example/ws"),
)
assert payload == {"ok": True}
assert call_count == 1
@pytest.mark.asyncio
async def test_openclaw_call_surfaces_scope_error_without_device_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_call_once(
method: str,
params: dict[str, object] | None,
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
del method, params, config, gateway_url
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
with pytest.raises(OpenClawGatewayError, match="missing scope: operator.read"):
await openclaw_call(
"status",
config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
)
class _FakeConnectContext:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> bool:
return False
@pytest.mark.asyncio
async def test_openclaw_call_once_does_not_pass_ssl_none_for_wss(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext:
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeConnectContext()
async def _fake_recv_first(_ws: object) -> None:
return None
async def _fake_ensure_connected(
_ws: object, _first_message: object, _config: GatewayConfig
) -> None:
return None
async def _fake_send_request(_ws: object, _method: str, _params: object) -> object:
return {"ok": True}
monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect)
monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first)
monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected)
monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request)
payload = await gateway_rpc._openclaw_call_once(
"status",
None,
config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=False),
gateway_url="wss://gateway.example/ws",
)
assert payload == {"ok": True}
assert captured["url"] == "wss://gateway.example/ws"
kwargs = captured["kwargs"]
assert isinstance(kwargs, dict)
assert "ssl" not in kwargs
@pytest.mark.asyncio
async def test_openclaw_call_once_passes_ssl_context_for_insecure_wss(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext:
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeConnectContext()
async def _fake_recv_first(_ws: object) -> None:
return None
async def _fake_ensure_connected(
_ws: object, _first_message: object, _config: GatewayConfig
) -> None:
return None
async def _fake_send_request(_ws: object, _method: str, _params: object) -> object:
return {"ok": True}
monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect)
monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first)
monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected)
monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request)
payload = await gateway_rpc._openclaw_call_once(
"status",
None,
config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=True),
gateway_url="wss://gateway.example/ws",
)
assert payload == {"ok": True}
assert captured["url"] == "wss://gateway.example/ws"
kwargs = captured["kwargs"]
assert isinstance(kwargs, dict)
assert kwargs.get("ssl") is not None
+54
View File
@@ -0,0 +1,54 @@
"""Tests for SSL/TLS configuration in gateway RPC connections."""
from __future__ import annotations
import ssl
from app.services.openclaw.gateway_rpc import GatewayConfig, _create_ssl_context
def test_create_ssl_context_returns_none_for_ws_protocol() -> None:
"""SSL context should be None for non-secure websocket connections."""
config = GatewayConfig(url="ws://gateway.example:18789/ws")
ssl_context = _create_ssl_context(config)
assert ssl_context is None
def test_create_ssl_context_returns_none_for_wss_with_secure_mode() -> None:
"""SSL context should be None for wss:// with default verification (secure mode)."""
config = GatewayConfig(url="wss://gateway.example:18789/ws", allow_insecure_tls=False)
ssl_context = _create_ssl_context(config)
assert ssl_context is None
def test_create_ssl_context_disables_verification_when_allow_insecure_tls_true() -> None:
"""SSL context should disable certificate verification when allow_insecure_tls is True."""
config = GatewayConfig(url="wss://gateway.example:18789/ws", allow_insecure_tls=True)
ssl_context = _create_ssl_context(config)
assert ssl_context is not None
assert isinstance(ssl_context, ssl.SSLContext)
assert ssl_context.check_hostname is False
assert ssl_context.verify_mode == ssl.CERT_NONE
def test_create_ssl_context_respects_localhost_with_insecure_flag() -> None:
"""SSL context for localhost should respect allow_insecure_tls flag."""
config = GatewayConfig(url="wss://localhost:18789/ws", allow_insecure_tls=True)
ssl_context = _create_ssl_context(config)
assert ssl_context is not None
assert ssl_context.check_hostname is False
assert ssl_context.verify_mode == ssl.CERT_NONE
def test_create_ssl_context_respects_ip_address_with_insecure_flag() -> None:
"""SSL context for IP addresses should respect allow_insecure_tls flag."""
config = GatewayConfig(url="wss://192.168.1.100:18789/ws", allow_insecure_tls=True)
ssl_context = _create_ssl_context(config)
assert ssl_context is not None
assert ssl_context.check_hostname is False
assert ssl_context.verify_mode == ssl.CERT_NONE
+197 -45
View File
@@ -16,104 +16,216 @@ from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayErro
from app.services.openclaw.session_service import GatewaySessionService
def test_extract_gateway_version_prefers_primary_path() -> None:
def test_extract_connect_server_version_uses_server_version_as_source_of_truth() -> None:
payload = {
"gateway": {"version": "2026.2.1"},
"protocolVersion": 3,
"meta": {"version": "2026.1.30"},
"version": "dev",
"runtime": {"version": "2026.1.0"},
"server": {"version": "2026.2.21-2"},
}
assert gateway_compat.extract_gateway_version(payload) == "2026.2.1"
assert gateway_compat.extract_connect_server_version(payload) == "2026.2.21-2"
def test_evaluate_gateway_version_detects_old_runtime() -> None:
def test_extract_connect_server_version_returns_none_when_server_version_missing() -> None:
payload = {
"version": "2026.2.21-2",
"runtime": {"version": "2026.2.21-2"},
}
assert gateway_compat.extract_connect_server_version(payload) is None
def test_extract_config_last_touched_version_reads_config_meta_last_touched_version() -> None:
payload = {
"config": {
"meta": {"lastTouchedVersion": "2026.2.9"},
"wizard": {"lastRunVersion": "2026.2.8"},
},
"parsed": {"meta": {"lastTouchedVersion": "2026.2.7"}},
}
assert gateway_compat.extract_config_last_touched_version(payload) == "2026.2.9"
def test_extract_config_last_touched_version_returns_none_without_config_meta_last_touched_version() -> (
None
):
payload = {
"config": {"wizard": {"lastRunVersion": "2026.2.9"}},
}
assert gateway_compat.extract_config_last_touched_version(payload) is None
@pytest.mark.parametrize(
("current_version", "minimum_version", "expected_compatible"),
[
("2026.2.21", "2026.2.21", True),
("2026.02.20", "2026.2.20", True),
("2026.2.22", "2026.2.21", True),
("2026.2.21-2", "2026.2.21-1", True),
("2026.2.21-1", "2026.2.21-2", False),
("2026.2.20", "2026.2.21", False),
],
)
def test_evaluate_gateway_version_compares_calver(
*,
current_version: str,
minimum_version: str,
expected_compatible: bool,
) -> None:
result = gateway_compat.evaluate_gateway_version(
current_version="2025.12.1",
current_version=current_version,
minimum_version=minimum_version,
)
assert result.compatible is expected_compatible
assert result.current_version == current_version
assert result.minimum_version == minimum_version
@pytest.mark.parametrize("invalid_current", ["dev", "latest", "2026.13.1", "2026.2.0-beta"])
def test_evaluate_gateway_version_rejects_non_calver_current(invalid_current: str) -> None:
result = gateway_compat.evaluate_gateway_version(
current_version=invalid_current,
minimum_version="2026.1.30",
)
assert result.compatible is False
assert result.minimum_version == "2026.1.30"
assert "Minimum supported version is 2026.1.30" in (result.message or "")
assert result.current_version == invalid_current
assert "unsupported version format" in (result.message or "").lower()
def test_evaluate_gateway_version_rejects_non_calver_minimum_version() -> None:
result = gateway_compat.evaluate_gateway_version(
current_version="2026.2.21",
minimum_version="1.2.3",
)
assert result.compatible is False
assert result.minimum_version == "1.2.3"
assert "expected calver" in (result.message or "").lower()
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_prefers_schema_version(
async def test_check_gateway_version_compatibility_uses_connect_server_version_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return {
"version": "dev",
"runtime": {"version": "2026.1.0"},
"server": {"version": "2026.2.13"},
}
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
return {"version": "2026.2.13"}
raise AssertionError(f"unexpected method: {method}")
_ = (method, params, config)
raise AssertionError("config.get fallback should not run for valid connect version")
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
result = await gateway_compat.check_gateway_version_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema"]
assert result.compatible is True
assert result.current_version == "2026.2.13"
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_falls_back_to_health(
async def test_check_gateway_version_compatibility_fails_without_server_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return {"runtime": {"version": "2026.2.13"}}
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
raise OpenClawGatewayError("unknown method")
if method == "status":
raise OpenClawGatewayError("unknown method")
return {"version": "2026.2.0"}
assert method == "config.get"
return {"config": {}}
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
result = await gateway_compat.check_gateway_version_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema", "status", "health"]
assert result.compatible is True
assert result.current_version == "2026.2.0"
assert result.compatible is False
assert result.current_version is None
assert "unable to determine gateway version" in (result.message or "").lower()
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_no_version(
async def test_check_gateway_version_compatibility_uses_config_get_fallback_when_connect_is_dev(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return {"server": {"version": "dev"}}
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
return {"schema": {"title": "Gateway schema"}}
if method == "status":
return {"uptime": 1234}
return {"version": "2026.2.0"}
assert method == "config.get"
return {"config": {"meta": {"lastTouchedVersion": "2026.2.9"}}}
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
result = await gateway_compat.check_gateway_version_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema", "status", "health"]
assert result.compatible is True
assert result.current_version == "2026.2.0"
assert result.current_version == "2026.2.9"
@pytest.mark.asyncio
async def test_check_gateway_version_compatibility_rejects_non_calver_server_version_when_fallback_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return {"server": {"version": "dev"}}
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (method, params, config)
raise OpenClawGatewayError("method unavailable")
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_version_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert result.compatible is False
assert result.current_version == "dev"
assert "unsupported version format" in (result.message or "").lower()
@pytest.mark.asyncio
async def test_check_gateway_version_compatibility_propagates_connect_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
raise OpenClawGatewayError("connection refused")
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
with pytest.raises(OpenClawGatewayError, match="connection refused"):
await gateway_compat.check_gateway_version_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
@pytest.mark.asyncio
@@ -129,7 +241,7 @@ async def test_admin_service_rejects_incompatible_gateway(
message="Gateway version 2026.1.0 is not supported.",
)
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
@@ -147,7 +259,7 @@ async def test_admin_service_maps_gateway_transport_errors(
_ = (config, minimum_version)
raise OpenClawGatewayError("connection refused")
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
@@ -157,6 +269,24 @@ async def test_admin_service_maps_gateway_transport_errors(
assert "compatibility check failed" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_admin_service_maps_gateway_scope_errors_with_guidance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
assert exc_info.value.status_code == 502
assert "missing required scope `operator.read`" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_gateway_status_reports_incompatible_version(
monkeypatch: pytest.MonkeyPatch,
@@ -170,7 +300,7 @@ async def test_gateway_status_reports_incompatible_version(
message="Gateway version 2026.1.0 is not supported.",
)
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
response = await service.get_status(
@@ -183,6 +313,28 @@ async def test_gateway_status_reports_incompatible_version(
assert response.error == "Gateway version 2026.1.0 is not supported."
@pytest.mark.asyncio
async def test_gateway_status_surfaces_scope_error_guidance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
response = await service.get_status(
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
organization_id=uuid4(),
user=None,
)
assert response.connected is False
assert response.error is not None
assert "missing required scope `operator.read`" in response.error
@pytest.mark.asyncio
async def test_gateway_status_returns_sessions_when_version_compatible(
monkeypatch: pytest.MonkeyPatch,
@@ -201,7 +353,7 @@ async def test_gateway_status_returns_sessions_when_version_compatible(
assert method == "sessions.list"
return {"sessions": [{"key": "agent:main"}]}
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
@@ -0,0 +1,126 @@
# ruff: noqa: INP001
"""Queue payload helpers for lifecycle reconcile tasks."""
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
import pytest
from app.core.time import utcnow
from app.services.openclaw.lifecycle_queue import (
QueuedAgentLifecycleReconcile,
decode_lifecycle_task,
defer_lifecycle_reconcile,
enqueue_lifecycle_reconcile,
)
from app.services.queue import QueuedTask
def test_enqueue_lifecycle_reconcile_uses_delayed_enqueue(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_enqueue_with_delay(
task: QueuedTask,
queue_name: str,
*,
delay_seconds: float,
redis_url: str | None = None,
) -> bool:
captured["task"] = task
captured["queue_name"] = queue_name
captured["delay_seconds"] = delay_seconds
captured["redis_url"] = redis_url
return True
monkeypatch.setattr(
"app.services.openclaw.lifecycle_queue.enqueue_task_with_delay",
_fake_enqueue_with_delay,
)
payload = QueuedAgentLifecycleReconcile(
agent_id=uuid4(),
gateway_id=uuid4(),
board_id=uuid4(),
generation=7,
checkin_deadline_at=utcnow() + timedelta(seconds=30),
attempts=0,
)
assert enqueue_lifecycle_reconcile(payload) is True
task = captured["task"]
assert isinstance(task, QueuedTask)
assert task.task_type == "agent_lifecycle_reconcile"
assert float(captured["delay_seconds"]) > 0
def test_defer_lifecycle_reconcile_keeps_attempt_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_enqueue_with_delay(
task: QueuedTask,
queue_name: str,
*,
delay_seconds: float,
redis_url: str | None = None,
) -> bool:
captured["task"] = task
captured["queue_name"] = queue_name
captured["delay_seconds"] = delay_seconds
captured["redis_url"] = redis_url
return True
monkeypatch.setattr(
"app.services.openclaw.lifecycle_queue.enqueue_task_with_delay",
_fake_enqueue_with_delay,
)
deadline = utcnow() + timedelta(minutes=1)
task = QueuedTask(
task_type="agent_lifecycle_reconcile",
payload={
"agent_id": str(uuid4()),
"gateway_id": str(uuid4()),
"board_id": None,
"generation": 3,
"checkin_deadline_at": deadline.isoformat(),
},
created_at=utcnow(),
attempts=2,
)
assert defer_lifecycle_reconcile(task, delay_seconds=12) is True
deferred_task = captured["task"]
assert isinstance(deferred_task, QueuedTask)
assert deferred_task.attempts == 2
assert float(captured["delay_seconds"]) == 12
def test_decode_lifecycle_task_roundtrip() -> None:
deadline = utcnow() + timedelta(minutes=3)
agent_id = uuid4()
gateway_id = uuid4()
board_id = uuid4()
task = QueuedTask(
task_type="agent_lifecycle_reconcile",
payload={
"agent_id": str(agent_id),
"gateway_id": str(gateway_id),
"board_id": str(board_id),
"generation": 5,
"checkin_deadline_at": deadline.isoformat(),
},
created_at=utcnow(),
attempts=1,
)
decoded = decode_lifecycle_task(task)
assert decoded.agent_id == agent_id
assert decoded.gateway_id == gateway_id
assert decoded.board_id == board_id
assert decoded.generation == 5
assert decoded.checkin_deadline_at == deadline
assert decoded.attempts == 1
@@ -0,0 +1,53 @@
# ruff: noqa: INP001
"""Lifecycle reconcile state helpers."""
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
from app.core.time import utcnow
from app.models.agents import Agent
from app.services.openclaw.constants import (
CHECKIN_DEADLINE_AFTER_WAKE,
MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN,
)
from app.services.openclaw.lifecycle_reconcile import _has_checked_in_since_wake
def _agent(*, last_seen_offset_s: int | None, last_wake_offset_s: int | None) -> Agent:
now = utcnow()
return Agent(
name="reconcile-test",
gateway_id=uuid4(),
last_seen_at=(
(now + timedelta(seconds=last_seen_offset_s))
if last_seen_offset_s is not None
else None
),
last_wake_sent_at=(
(now + timedelta(seconds=last_wake_offset_s))
if last_wake_offset_s is not None
else None
),
)
def test_checked_in_since_wake_when_last_seen_after_wake() -> None:
agent = _agent(last_seen_offset_s=5, last_wake_offset_s=0)
assert _has_checked_in_since_wake(agent) is True
def test_not_checked_in_since_wake_when_last_seen_before_wake() -> None:
agent = _agent(last_seen_offset_s=-5, last_wake_offset_s=0)
assert _has_checked_in_since_wake(agent) is False
def test_not_checked_in_since_wake_when_missing_last_seen() -> None:
agent = _agent(last_seen_offset_s=None, last_wake_offset_s=0)
assert _has_checked_in_since_wake(agent) is False
def test_lifecycle_convergence_policy_constants() -> None:
assert CHECKIN_DEADLINE_AFTER_WAKE == timedelta(seconds=30)
assert MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN == 3
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
import pytest
from app.api import metrics as metrics_api
from app.models.approvals import Approval
from app.models.boards import Board
from app.models.tasks import Task
class _ExecResult:
def __init__(self, rows: list[tuple[str, int]]) -> None:
self._rows = rows
def all(self) -> list[tuple[str, int]]:
return self._rows
class _FakeSession:
def __init__(self, rows: list[tuple[str, int]]) -> None:
self._rows = rows
async def exec(self, _statement: object) -> _ExecResult:
return _ExecResult(self._rows)
class _ExecOneResult:
def __init__(self, value: int) -> None:
self._value = value
def one(self) -> int:
return self._value
class _ExecAllResult:
def __init__(self, rows: list[tuple[object, ...]]) -> None:
self._rows = rows
def all(self) -> list[tuple[object, ...]]:
return self._rows
class _SequentialSession:
def __init__(self, responses: list[object]) -> None:
self._responses = responses
self._index = 0
async def exec(self, _statement: object) -> object:
response = self._responses[self._index]
self._index += 1
return response
@pytest.mark.asyncio
async def test_task_status_counts_returns_zeroes_for_empty_board_scope() -> None:
counts = await metrics_api._task_status_counts(_FakeSession([]), [])
assert counts == {
"inbox": 0,
"in_progress": 0,
"review": 0,
"done": 0,
}
@pytest.mark.asyncio
async def test_task_status_counts_maps_known_statuses() -> None:
session = _FakeSession(
[
("inbox", 4),
("in_progress", 3),
("review", 2),
("done", 7),
("blocked", 99),
],
)
counts = await metrics_api._task_status_counts(session, [uuid4()])
assert counts == {
"inbox": 4,
"in_progress": 3,
"review": 2,
"done": 7,
}
@pytest.mark.asyncio
async def test_pending_approvals_snapshot_returns_empty_for_empty_scope() -> None:
snapshot = await metrics_api._pending_approvals_snapshot(_SequentialSession([]), [])
assert snapshot.total == 0
assert snapshot.items == []
@pytest.mark.asyncio
async def test_pending_approvals_snapshot_maps_rows() -> None:
approval_id = uuid4()
board_id = uuid4()
organization_id = uuid4()
task_id = uuid4()
created_at = datetime(2026, 3, 4, 12, 0, 0)
approval = Approval(
id=approval_id,
board_id=board_id,
task_id=task_id,
action_type="approve_task",
confidence=87.0,
created_at=created_at,
status="pending",
)
board = Board(
id=board_id,
organization_id=organization_id,
name="Operations Board",
slug="operations-board",
)
task = Task(
id=task_id,
board_id=board_id,
title="Validate rollout checklist",
)
rows: list[tuple[object, ...]] = [
(
approval,
board,
task,
)
]
session = _SequentialSession(
[
_ExecOneResult(3),
_ExecAllResult(rows),
]
)
snapshot = await metrics_api._pending_approvals_snapshot(session, [board_id], limit=10)
assert snapshot.total == 3
assert len(snapshot.items) == 1
item = snapshot.items[0]
assert item.approval_id == approval_id
assert item.board_id == board_id
assert item.board_name == "Operations Board"
assert item.action_type == "approve_task"
assert item.confidence == 87.0
assert item.created_at == created_at
assert item.task_title == "Validate rollout checklist"
@@ -0,0 +1,16 @@
# ruff: noqa: INP001, S101
from __future__ import annotations
from app.main import app
def test_openapi_includes_agent_webhook_payload_read_endpoint() -> None:
schema = app.openapi()
path = "/api/v1/agent/boards/{board_id}/webhooks/{webhook_id}/payloads/{payload_id}"
assert path in schema["paths"]
op = schema["paths"][path]["get"]
tags = set(op.get("tags", []))
assert "agent-worker" in tags
assert op.get("x-llm-intent") == "agent_board_webhook_payload_read"
@@ -0,0 +1,34 @@
# ruff: noqa: INP001, S101
from __future__ import annotations
from app.main import app
def test_openapi_blocked_task_error_includes_code_field() -> None:
schema = app.openapi()
blocked_detail = schema["components"]["schemas"]["BlockedTaskDetail"]
props = blocked_detail.get("properties", {})
# `code` is optional but must be documented for clients.
assert "code" in props
required_fields = blocked_detail.get("required", [])
assert "code" not in required_fields
code_schema = props["code"]
any_of = code_schema.get("anyOf")
if any_of:
assert isinstance(any_of, list)
has_string_branch = any(branch.get("type") == "string" for branch in any_of)
assert has_string_branch
has_null_branch = any(
branch.get("type") == "null" or branch.get("nullable") is True for branch in any_of
)
assert has_null_branch
else:
# Alternative encoding used by some schema versions for Optional[str].
assert code_schema.get("type") == "string"
assert code_schema.get("nullable") is True
@@ -0,0 +1,11 @@
# ruff: noqa: INP001
"""Queue worker registration tests for lifecycle reconcile tasks."""
from __future__ import annotations
from app.services.openclaw.lifecycle_queue import TASK_TYPE as LIFECYCLE_TASK_TYPE
from app.services.queue_worker import _TASK_HANDLERS
def test_worker_registers_lifecycle_reconcile_handler() -> None:
assert LIFECYCLE_TASK_TYPE in _TASK_HANDLERS
@@ -0,0 +1,147 @@
from __future__ import annotations
import pytest
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
from app.core.security_headers import SecurityHeadersMiddleware
@pytest.mark.asyncio
async def test_security_headers_middleware_passes_through_non_http_scope() -> None:
called = False
async def app(scope, receive, send): # type: ignore[no-untyped-def]
_ = receive
_ = send
nonlocal called
called = scope["type"] == "websocket"
middleware = SecurityHeadersMiddleware(app, x_frame_options="SAMEORIGIN")
await middleware({"type": "websocket", "headers": []}, lambda: None, lambda _: None)
assert called is True
@pytest.mark.asyncio
async def test_security_headers_middleware_appends_lowercase_raw_header_names() -> None:
sent_messages: list[dict[str, object]] = []
async def app(scope, receive, send): # type: ignore[no-untyped-def]
_ = scope
_ = receive
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"", "more_body": False})
async def capture(message): # type: ignore[no-untyped-def]
sent_messages.append(message)
middleware = SecurityHeadersMiddleware(app, x_frame_options="SAMEORIGIN")
await middleware(
{"type": "http", "method": "GET", "path": "/", "headers": []}, lambda: None, capture
)
response_start = next(
message for message in sent_messages if message.get("type") == "http.response.start"
)
headers = response_start.get("headers")
assert isinstance(headers, list)
header_names = {name for name, _value in headers}
assert b"x-frame-options" in header_names
assert b"X-Frame-Options" not in header_names
def test_security_headers_middleware_injects_configured_headers() -> None:
app = FastAPI()
app.add_middleware(
SecurityHeadersMiddleware,
x_content_type_options="nosniff",
x_frame_options="SAMEORIGIN",
referrer_policy="strict-origin-when-cross-origin",
permissions_policy="camera=(), microphone=(), geolocation=()",
)
@app.get("/ok")
def ok() -> dict[str, bool]:
return {"ok": True}
response = TestClient(app).get("/ok")
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "SAMEORIGIN"
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"
assert response.headers["permissions-policy"] == "camera=(), microphone=(), geolocation=()"
def test_security_headers_middleware_does_not_override_existing_values() -> None:
app = FastAPI()
app.add_middleware(
SecurityHeadersMiddleware,
x_content_type_options="nosniff",
x_frame_options="SAMEORIGIN",
referrer_policy="strict-origin-when-cross-origin",
permissions_policy="camera=(), microphone=(), geolocation=()",
)
@app.get("/already-set")
def already_set(response: Response) -> dict[str, bool]:
response.headers["X-Frame-Options"] = "ALLOWALL"
response.headers["Referrer-Policy"] = "unsafe-url"
return {"ok": True}
response = TestClient(app).get("/already-set")
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "ALLOWALL"
assert response.headers["referrer-policy"] == "unsafe-url"
assert response.headers["permissions-policy"] == "camera=(), microphone=(), geolocation=()"
def test_security_headers_middleware_includes_headers_on_cors_preflight() -> None:
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(
SecurityHeadersMiddleware,
x_content_type_options="nosniff",
)
@app.get("/ok")
def ok() -> dict[str, bool]:
return {"ok": True}
response = TestClient(app).options(
"/ok",
headers={
"Origin": "https://example.com",
"Access-Control-Request-Method": "GET",
},
)
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
def test_security_headers_middleware_skips_blank_config_values() -> None:
app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)
@app.get("/ok")
def ok() -> dict[str, bool]:
return {"ok": True}
response = TestClient(app).get("/ok")
assert response.status_code == 200
assert response.headers.get("x-content-type-options") is None
assert response.headers.get("x-frame-options") is None
assert response.headers.get("referrer-policy") is None
assert response.headers.get("permissions-policy") is None
+363 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
@@ -11,6 +12,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.api import tasks as tasks_api
from app.api.deps import ActorContext
from app.core.time import utcnow
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
@@ -326,7 +328,7 @@ async def test_non_lead_agent_forbidden_for_lead_only_patch_fields() -> None:
@pytest.mark.asyncio
async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None:
async def test_non_lead_agent_moves_task_to_review_and_reassigns_to_lead() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
@@ -334,6 +336,7 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None:
board_id = uuid4()
gateway_id = uuid4()
worker_id = uuid4()
lead_id = uuid4()
task_id = uuid4()
in_progress_at = utcnow()
@@ -365,6 +368,16 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None:
status="online",
),
)
session.add(
Agent(
id=lead_id,
name="Lead Agent",
board_id=board_id,
gateway_id=gateway_id,
status="online",
is_board_lead=True,
),
)
session.add(
Task(
id=task_id,
@@ -391,7 +404,7 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None:
)
assert updated.status == "review"
assert updated.assigned_agent_id is None
assert updated.assigned_agent_id == lead_id
assert updated.in_progress_at is None
refreshed_task = (
@@ -399,6 +412,268 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None:
).first()
assert refreshed_task is not None
assert refreshed_task.previous_in_progress_at == in_progress_at
assert refreshed_task.assigned_agent_id == lead_id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_non_lead_agent_move_to_review_reassigns_to_lead_and_sends_review_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
org_id = uuid4()
board_id = uuid4()
gateway_id = uuid4()
worker_id = uuid4()
lead_id = uuid4()
task_id = uuid4()
session.add(Organization(id=org_id, name="org"))
session.add(
Gateway(
id=gateway_id,
organization_id=org_id,
name="gateway",
url="https://gateway.local",
workspace_root="/tmp/workspace",
),
)
session.add(
Board(
id=board_id,
organization_id=org_id,
name="board",
slug="board",
gateway_id=gateway_id,
),
)
session.add(
Agent(
id=worker_id,
name="worker",
board_id=board_id,
gateway_id=gateway_id,
status="online",
),
)
session.add(
Agent(
id=lead_id,
name="Lead Agent",
board_id=board_id,
gateway_id=gateway_id,
status="online",
is_board_lead=True,
openclaw_session_id="lead-session",
),
)
session.add(
Task(
id=task_id,
board_id=board_id,
title="assigned task",
description="done and ready",
status="in_progress",
assigned_agent_id=worker_id,
in_progress_at=utcnow(),
),
)
await session.commit()
sent: dict[str, str] = {}
class _FakeDispatch:
def __init__(self, _session: AsyncSession) -> None:
pass
async def optional_gateway_config_for_board(self, _board: Board) -> object:
return object()
async def _fake_send_agent_task_message(
*,
dispatch: Any,
session_key: str,
config: Any,
agent_name: str,
message: str,
) -> None:
_ = dispatch, config
sent["session_key"] = session_key
sent["agent_name"] = agent_name
sent["message"] = message
return None
monkeypatch.setattr(tasks_api, "GatewayDispatchService", _FakeDispatch)
monkeypatch.setattr(
tasks_api, "_send_agent_task_message", _fake_send_agent_task_message
)
task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert task is not None
actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first()
assert actor is not None
updated = await tasks_api.update_task(
payload=TaskUpdate(status="review", comment="Moving to review."),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=actor),
)
assert updated.status == "review"
assert updated.assigned_agent_id == lead_id
assert sent["session_key"] == "lead-session"
assert sent["agent_name"] == "Lead Agent"
assert "TASK READY FOR LEAD REVIEW" in sent["message"]
assert "review the deliverables" in sent["message"]
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_lead_moves_review_task_to_inbox_and_reassigns_last_worker_with_rework_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
org_id = uuid4()
board_id = uuid4()
gateway_id = uuid4()
worker_id = uuid4()
lead_id = uuid4()
task_id = uuid4()
session.add(Organization(id=org_id, name="org"))
session.add(
Gateway(
id=gateway_id,
organization_id=org_id,
name="gateway",
url="https://gateway.local",
workspace_root="/tmp/workspace",
),
)
session.add(
Board(
id=board_id,
organization_id=org_id,
name="board",
slug="board",
gateway_id=gateway_id,
),
)
session.add(
Agent(
id=worker_id,
name="worker",
board_id=board_id,
gateway_id=gateway_id,
status="online",
openclaw_session_id="worker-session",
),
)
session.add(
Agent(
id=lead_id,
name="Lead Agent",
board_id=board_id,
gateway_id=gateway_id,
status="online",
is_board_lead=True,
openclaw_session_id="lead-session",
),
)
session.add(
Task(
id=task_id,
board_id=board_id,
title="assigned task",
description="ready",
status="in_progress",
assigned_agent_id=worker_id,
in_progress_at=utcnow(),
),
)
await session.commit()
sent: list[dict[str, str]] = []
class _FakeDispatch:
def __init__(self, _session: AsyncSession) -> None:
pass
async def optional_gateway_config_for_board(self, _board: Board) -> object:
return object()
async def _fake_send_agent_task_message(
*,
dispatch: Any,
session_key: str,
config: Any,
agent_name: str,
message: str,
) -> None:
_ = dispatch, config
sent.append(
{
"session_key": session_key,
"agent_name": agent_name,
"message": message,
},
)
return None
monkeypatch.setattr(tasks_api, "GatewayDispatchService", _FakeDispatch)
monkeypatch.setattr(
tasks_api, "_send_agent_task_message", _fake_send_agent_task_message
)
task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert task is not None
worker = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first()
assert worker is not None
lead = (await session.exec(select(Agent).where(col(Agent.id) == lead_id))).first()
assert lead is not None
moved_to_review = await tasks_api.update_task(
payload=TaskUpdate(status="review", comment="Ready for review."),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=worker),
)
assert moved_to_review.status == "review"
assert moved_to_review.assigned_agent_id == lead_id
session.add(
ActivityEvent(
event_type="task.comment",
task_id=task_id,
agent_id=lead_id,
message="Please update error handling and add tests for edge cases.",
),
)
await session.commit()
review_task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert review_task is not None
reverted = await tasks_api.update_task(
payload=TaskUpdate(status="inbox"),
task=review_task,
session=session,
actor=ActorContext(actor_type="agent", agent=lead),
)
assert reverted.status == "inbox"
assert reverted.assigned_agent_id == worker_id
worker_messages = [item for item in sent if item["session_key"] == "worker-session"]
assert worker_messages
final_message = worker_messages[-1]["message"]
assert "CHANGES REQUESTED" in final_message
assert "Please update error handling and add tests for edge cases." in final_message
finally:
await engine.dispose()
@@ -485,7 +760,91 @@ async def test_non_lead_agent_comment_in_review_without_status_does_not_reassign
@pytest.mark.asyncio
async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_fails() -> None:
async def test_non_lead_agent_moves_to_review_without_comment_when_rule_disabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
org_id = uuid4()
board_id = uuid4()
gateway_id = uuid4()
worker_id = uuid4()
lead_id = uuid4()
task_id = uuid4()
session.add(Organization(id=org_id, name="org"))
session.add(
Gateway(
id=gateway_id,
organization_id=org_id,
name="gateway",
url="https://gateway.local",
workspace_root="/tmp/workspace",
),
)
session.add(
Board(
id=board_id,
organization_id=org_id,
name="board",
slug="board",
gateway_id=gateway_id,
comment_required_for_review=False,
),
)
session.add(
Agent(
id=worker_id,
name="worker",
board_id=board_id,
gateway_id=gateway_id,
status="online",
),
)
session.add(
Agent(
id=lead_id,
name="Lead Agent",
board_id=board_id,
gateway_id=gateway_id,
status="online",
is_board_lead=True,
),
)
session.add(
Task(
id=task_id,
board_id=board_id,
title="assigned task",
description="",
status="in_progress",
assigned_agent_id=worker_id,
in_progress_at=utcnow(),
),
)
await session.commit()
task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert task is not None
actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first()
assert actor is not None
updated = await tasks_api.update_task(
payload=TaskUpdate(status="review"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=actor),
)
assert updated.status == "review"
assert updated.assigned_agent_id == lead_id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_fails_when_rule_enabled() -> (
None
):
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
@@ -512,6 +871,7 @@ async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_
name="board",
slug="board",
gateway_id=gateway_id,
comment_required_for_review=True,
),
)
session.add(
@@ -0,0 +1,138 @@
# ruff: noqa: INP001
from __future__ import annotations
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext
from app.api.tasks import _apply_lead_task_update, _TaskUpdateInput
from app.models.agents import Agent
from app.models.boards import Board
from app.models.organizations import Organization
from app.models.task_dependencies import TaskDependency
from app.models.tasks import Task
from app.services.task_dependencies import blocked_by_for_task
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.connect() as conn, conn.begin():
await conn.run_sync(SQLModel.metadata.create_all)
return engine
async def _make_session(engine: AsyncEngine) -> AsyncSession:
return AsyncSession(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_lead_dependency_only_update_allowed_when_task_blocked() -> None:
"""Leads may update dependencies even if the task is currently blocked.
This supports unblocking work by adjusting dependency graphs, while still
rejecting status/assignee transitions.
"""
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
org_id = uuid4()
board_id = uuid4()
lead_id = uuid4()
dep_id = uuid4()
task_id = uuid4()
session.add(Organization(id=org_id, name="org"))
session.add(Board(id=board_id, organization_id=org_id, name="b", slug="b"))
session.add(
Agent(
id=lead_id,
name="Lead",
board_id=board_id,
gateway_id=uuid4(),
is_board_lead=True,
openclaw_session_id="agent:lead:session",
),
)
session.add(
Task(
id=dep_id,
board_id=board_id,
title="dep",
description=None,
status="inbox",
),
)
session.add(
Task(
id=task_id,
board_id=board_id,
title="t",
description=None,
status="review",
assigned_agent_id=None,
),
)
session.add(
TaskDependency(
board_id=board_id,
task_id=task_id,
depends_on_task_id=dep_id,
),
)
await session.commit()
lead = (await session.exec(select(Agent).where(col(Agent.id) == lead_id))).first()
task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert lead is not None
assert task is not None
blocked_by_before = await blocked_by_for_task(
session,
board_id=board_id,
task_id=task_id,
)
assert blocked_by_before == [dep_id]
# Re-assert the same deps list; this should be a no-op and should not
# be rejected solely because the task is blocked.
update = _TaskUpdateInput(
task=task,
actor=ActorContext(actor_type="agent", agent=lead),
board_id=board_id,
previous_status=task.status,
previous_assigned=task.assigned_agent_id,
status_requested=False,
updates={},
comment=None,
depends_on_task_ids=[dep_id],
tag_ids=None,
custom_field_values={},
custom_field_values_set=False,
)
result = await _apply_lead_task_update(session, update=update)
assert result.id == task_id
assert result.is_blocked is True
assert result.blocked_by_task_ids == [dep_id]
reloaded = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
assert reloaded is not None
assert reloaded.status == "review"
assert reloaded.assigned_agent_id is None
dependency_rows = (
await session.exec(
select(TaskDependency).where(
col(TaskDependency.task_id) == task_id,
col(TaskDependency.depends_on_task_id) == dep_id,
),
)
).all()
assert len(dependency_rows) == 1
finally:
await engine.dispose()
+6 -22
View File
@@ -292,12 +292,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
]
[[package]]
name = "crontab"
version = "1.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/36/a255b6f5a2e22df03fd2b2f3088974b44b8c9e9407e26b44742cb7cfbf5b/crontab-1.0.5.tar.gz", hash = "sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5", size = 21963, upload-time = "2025-07-09T17:09:38.264Z" }
[[package]]
name = "cryptography"
version = "45.0.7"
@@ -335,7 +329,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.128.6"
version = "0.131.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -344,9 +338,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" }
sdist = { url = "https://files.pythonhosted.org/packages/91/32/158cbf685b7d5a26f87131069da286bf10fc9fbf7fc968d169d48a45d689/fastapi-0.131.0.tar.gz", hash = "sha256:6531155e52bee2899a932c746c9a8250f210e3c3303a5f7b9f8a808bfe0548ff", size = 369612, upload-time = "2026-02-22T16:38:11.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" },
{ url = "https://files.pythonhosted.org/packages/ff/94/b58ec24c321acc2ad1327f69b033cadc005e0f26df9a73828c9e9c7db7ce/fastapi-0.131.0-py3-none-any.whl", hash = "sha256:ed0e53decccf4459de78837ce1b867cd04fa9ce4579497b842579755d20b405a", size = 103854, upload-time = "2026-02-22T16:38:09.814Z" },
]
[[package]]
@@ -377,18 +371,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
]
[[package]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -722,6 +704,7 @@ source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "clerk-backend-api" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "fastapi-pagination" },
{ name = "jinja2" },
@@ -759,7 +742,8 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = "==26.1.0" },
{ name = "clerk-backend-api", specifier = "==4.2.0" },
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.13.4" },
{ name = "fastapi", specifier = "==0.128.6" },
{ name = "cryptography", specifier = "==45.0.7" },
{ name = "fastapi", specifier = "==0.131.0" },
{ name = "fastapi-pagination", specifier = "==0.15.10" },
{ name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
+29 -7
View File
@@ -21,6 +21,11 @@ services:
image: redis:7-alpine
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build:
@@ -29,7 +34,7 @@ services:
context: .
dockerfile: backend/Dockerfile
env_file:
- ./backend/.env.example
- ./backend/.env
environment:
# Override localhost defaults for container networking
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
@@ -42,7 +47,7 @@ services:
db:
condition: service_healthy
redis:
condition: service_started
condition: service_healthy
ports:
- "${BACKEND_PORT:-8000}:8000"
@@ -50,7 +55,7 @@ services:
build:
context: ./frontend
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
# Optional, user-managed env file.
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
@@ -59,23 +64,40 @@ services:
- path: ./frontend/.env
required: false
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
develop:
watch:
# Rebuild frontend image when UI source or build config changes.
- action: rebuild
path: ./frontend/src
- action: rebuild
path: ./frontend/package.json
- action: rebuild
path: ./frontend/package-lock.json
- action: rebuild
path: ./frontend/next.config.ts
- action: rebuild
path: ./frontend/postcss.config.js
- action: rebuild
path: ./frontend/tailwind.config.cjs
- action: rebuild
path: ./frontend/tsconfig.json
webhook-worker:
build:
context: .
dockerfile: backend/Dockerfile
command: ["rq", "worker", "-u", "redis://redis:6379/0"]
command: ["python", "scripts/rq-docker", "worker"]
env_file:
- ./backend/.env.example
- ./backend/.env
depends_on:
redis:
condition: service_started
condition: service_healthy
db:
condition: service_healthy
environment:
+16 -9
View File
@@ -1,19 +1,26 @@
# Mission Control docs
This folder is the starting point for Mission Control documentation.
This folder is the documentation home for **OpenClaw Mission Control**.
## Sections
## Start here
- [Development workflow](./03-development.md)
- [Testing guide](./testing/README.md)
- [Coverage policy](./coverage-policy.md)
- [Getting started](./getting-started/README.md)
- [Development](./development/README.md)
- [Testing](./testing/README.md)
- [Deployment](./deployment/README.md)
- [Production notes](./production/README.md)
- [Release checklist](./release/README.md)
- [Operations](./operations/README.md)
- [Troubleshooting](./troubleshooting/README.md)
- [Gateway agent provisioning and check-in troubleshooting](./troubleshooting/gateway-agent-provisioning.md)
- [Gateway WebSocket protocol](./openclaw_gateway_ws.md)
- [OpenClaw baseline configuration](./openclaw_baseline_config.md)
## Status
## Reference
These pages are minimal placeholders so repo-relative links stay healthy. The actual docs
information architecture will be defined in the Docs overhaul tasks.
- [Configuration reference](./reference/configuration.md)
- [Authentication](./reference/authentication.md)
- [API notes](./reference/api.md)
## Contributing to docs
- [Docs style guide](./style-guide.md)
+10
View File
@@ -0,0 +1,10 @@
# Architecture
## High level
- Frontend: Next.js
- Backend: FastAPI
- Database: Postgres
> **Note**
> Add component diagrams and key data flows (auth, task lifecycle, gateway integration) as they solidify.
+99 -2
View File
@@ -1,3 +1,100 @@
# Deployment guide
# Deployment
Placeholder.
This section covers deploying Mission Control in self-hosted environments.
> **Goal**
> A simple, reproducible deploy that preserves the Postgres volume and supports safe upgrades.
## Deployment mode: single host (Docker Compose)
### Prerequisites
- Docker + Docker Compose v2 (`docker compose`)
- A host where the **browser** can reach the backend URL you configure (see `NEXT_PUBLIC_API_URL` below)
### 1) Configure environment
From repo root:
```bash
cp .env.example .env
```
Edit `.env`:
- `AUTH_MODE=local` (default)
- **Set** `LOCAL_AUTH_TOKEN` to a non-placeholder value (≥ 50 chars)
- Ensure `NEXT_PUBLIC_API_URL` is reachable from the browser (not a Docker-internal hostname)
Key variables (from `.env.example` / `compose.yml`):
- Frontend: `FRONTEND_PORT` (default `3000`)
- Backend: `BACKEND_PORT` (default `8000`)
- Postgres: `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_PORT`
- Backend:
- `DB_AUTO_MIGRATE` (default `true` in compose)
- `CORS_ORIGINS` (default `http://localhost:3000`)
### 2) Start the stack
```bash
docker compose -f compose.yml --env-file .env up -d --build
```
Open:
- Frontend: `http://localhost:${FRONTEND_PORT:-3000}`
- Backend health: `http://localhost:${BACKEND_PORT:-8000}/healthz`
### 3) Verify
```bash
curl -f "http://localhost:${BACKEND_PORT:-8000}/healthz"
```
If the frontend loads but API calls fail, double-check:
- `NEXT_PUBLIC_API_URL` is set and reachable from the **browser**
- backend CORS includes the frontend origin (`CORS_ORIGINS`)
## Database persistence
The Compose stack uses a named volume:
- `postgres_data``/var/lib/postgresql/data`
This means:
- `docker compose ... down` preserves data
- `docker compose ... down -v` is **destructive** (deletes the DB volume)
## Migrations / upgrades
### Default behavior in Compose
In `compose.yml`, the backend container defaults:
- `DB_AUTO_MIGRATE=true`
So on startup the backend will attempt to run Alembic migrations automatically.
> **Warning**
> For zero/near-zero downtime, migrations must be **backward compatible** with the currently running app if you do rolling deploys.
### Safer operator pattern (manual migrations)
If you want more control, set `DB_AUTO_MIGRATE=false` and run migrations explicitly during deploy:
```bash
cd backend
uv run alembic upgrade head
```
## Reverse proxy / TLS
Typical setup (outline):
- Put the frontend behind HTTPS (reverse proxy)
- Ensure the frontend can reach the backend over the configured `NEXT_PUBLIC_API_URL`
This section is intentionally minimal until we standardize a recommended proxy (Caddy/Nginx/Traefik).
+59
View File
@@ -0,0 +1,59 @@
# Development
This section is for contributors developing Mission Control locally.
## Recommended workflow (fast loop)
Run Postgres in Docker, run backend + frontend on your host.
### 1) Start Postgres
From repo root:
```bash
cp .env.example .env
docker compose -f compose.yml --env-file .env up -d db
```
### 2) Run the backend (dev)
```bash
cd backend
cp .env.example .env
uv sync --extra dev
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
Verify:
```bash
curl -f http://localhost:8000/healthz
```
### 3) Run the frontend (dev)
```bash
cd frontend
cp .env.example .env.local
npm install
npm run dev
```
Open http://localhost:3000.
## Useful repo-root commands
```bash
make help
make setup
make check
```
- `make setup`: sync backend + frontend deps
- `make check`: lint + typecheck + tests + build (closest CI parity)
## Related docs
- [Testing](../testing/README.md)
- [Release checklist](../release/README.md)
+30
View File
@@ -0,0 +1,30 @@
# Getting started
## What is Mission Control?
Mission Control is the web UI and HTTP API for operating OpenClaw.
It provides a control plane for boards, tasks, agents, approvals, and (optionally) gateway connections.
## Quickstart (Docker Compose)
From repo root:
```bash
cp .env.example .env
# REQUIRED when AUTH_MODE=local
# Set LOCAL_AUTH_TOKEN to a non-placeholder value with at least 50 characters.
docker compose -f compose.yml --env-file .env up -d --build
```
Open:
- Frontend: http://localhost:3000
- Backend health: http://localhost:8000/healthz
## Next steps
- [Authentication](../reference/authentication.md)
- [Deployment](../deployment/README.md)
- [Development](../development/README.md)
+1
View File
@@ -17,6 +17,7 @@ This document defines current support status for `./install.sh`.
| openSUSE | `zypper` | **Scaffolded** | Detection + actionable commands present; auto-install path is TODO. |
| Arch Linux | `pacman` | **Scaffolded** | Detection + actionable commands present; auto-install path is TODO. |
| Other Linux distros | unknown | **Unsupported** | Installer exits with package-manager guidance requirement. |
| macOS (Darwin) | Homebrew | **Stable** | Docker mode requires Docker Desktop. Local mode uses Homebrew for curl, git, make, openssl, Node.js. |
## Guard rails
+3
View File
@@ -479,6 +479,9 @@ When adding a gateway in Mission Control:
- URL: `ws://127.0.0.1:18789` (or your host/IP with explicit port)
- Token: provide only if your gateway requires token auth
- Device pairing: enabled by default and recommended
- Keep pairing enabled for normal operation.
- Optional bypass: enable `Disable device pairing` per gateway only when the gateway is explicitly configured for control UI auth bypass (for example `gateway.controlUi.dangerouslyDisableDeviceAuth: true` plus appropriate `gateway.controlUi.allowedOrigins`).
- Workspace root (in Mission Control gateway config): align with `agents.defaults.workspace` when possible
## Security Notes
+28 -1
View File
@@ -1,3 +1,30 @@
# Gateway WebSocket protocol
Placeholder.
## Connection Types
OpenClaw Mission Control supports both secure (`wss://`) and non-secure (`ws://`) WebSocket connections to gateways.
### Secure Connections (wss://)
For production environments, always use `wss://` (WebSocket Secure) connections with valid TLS certificates.
### Self-Signed Certificates
You can enable support for self-signed TLS certificates with a toggle:
1. Navigate to the gateway configuration page (Settings → Gateways)
2. When creating or editing a gateway, enable: **"Allow self-signed TLS certificates"**
3. This applies to any `wss://` gateway URL for that gateway configuration.
When enabled, Mission Control skips TLS certificate verification for that gateway connection.
**Security Warning**: Enabling this weakens transport security and should only be used when you explicitly trust the endpoint and network path. Prefer valid CA-signed certificates for production gateways.
## Configuration Options
When configuring a gateway, you can specify:
- **Gateway URL**: The WebSocket endpoint (e.g., `wss://localhost:18789` or `ws://gateway:18789`)
- **Gateway Token**: Optional authentication token
- **Workspace Root**: The root directory for gateway files (e.g., `~/.openclaw`)
- **Allow self-signed TLS certificates**: Toggle TLS certificate verification off for this gateway's `wss://` connections (default: disabled)

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