mirror of
https://github.com/abhi1693/openclaw-mission-control.git
synced 2026-08-14 01:07:51 +00:00
Merge master into security/bind-ports-to-localhost: keep localhost port binding and redis healthcheck
This commit is contained in:
+4
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -22,4 +22,5 @@ node_modules/
|
||||
backend/~/
|
||||
backend/coverage.*
|
||||
backend/.coverage
|
||||
frontend/coverage
|
||||
frontend/coverage
|
||||
backend/app/services/openclaw/.device-keys
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenClaw Mission Control
|
||||
|
||||
[](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml)
|
||||
[](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml) 
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+43
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }}`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Generated
+6
-22
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user