From 7baa89254c98febd369c13aab31fe5a745eff1dd Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:58:37 +0000 Subject: [PATCH 01/33] ops(cloud): deploy exact production stack to jp09 --- .github/workflows/deploy-prod.yml | 77 +++++++++++++++ deploy/prod/deploy.sh | 74 ++++++++++++++ deploy/prod/docker-compose.yml | 157 ++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 .github/workflows/deploy-prod.yml create mode 100755 deploy/prod/deploy.sh create mode 100644 deploy/prod/docker-compose.yml diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 000000000..a23a9f239 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,77 @@ +name: Build and deploy production + +on: + push: + branches: [deploy/prod] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: langbot-production + cancel-in-progress: false + +env: + CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot + CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core + SPACE_REF: e1b261dac45e886efc667b1096a4ec493c6a6111 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build exact Core image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + ${{ env.CORE_IMAGE }}:deploy-prod + cache-from: type=gha,scope=core-prod + cache-to: type=gha,mode=max,scope=core-prod + - name: Checkout production Cloud adapter + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-space + ref: ${{ env.SPACE_REF }} + path: .space + - name: Build exact Cloud Core image + uses: docker/build-push-action@v6 + with: + context: .space + file: .space/Dockerfile.cloud + push: true + build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + tags: | + ${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }} + ${{ env.CLOUD_IMAGE }}:deploy-prod + cache-from: type=gha,scope=cloud-core-prod + cache-to: type=gha,mode=max,scope=cloud-core-prod + - name: Configure SSH + env: + SSH_KEY: ${{ secrets.JP09_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }} + run: | + install -m 700 -d ~/.ssh + install -m 600 /dev/null ~/.ssh/id_ed25519 + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + - name: Upload release manifest and deploy + env: + HOST: ${{ secrets.JP09_HOST }} + USER: ${{ secrets.JP09_USER }} + PORT: ${{ secrets.JP09_PORT }} + run: | + remote="$USER@$HOST" + ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod' + scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/" + ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}" diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh new file mode 100755 index 000000000..1c5f0917b --- /dev/null +++ b/deploy/prod/deploy.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd /opt/langbot-cloud-prod +TAG=${1:?usage: deploy.sh prod-<40-char-sha>} +[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; } +[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; } + +update_env() { + local key=$1 value=$2 + python3 - "$key" "$value" <<'PY' +from pathlib import Path +import os +import sys + +path = Path('.env') +key, value = sys.argv[1:] +lines = path.read_text().splitlines() +updated = False +for index, line in enumerate(lines): + if line.startswith(f'{key}='): + lines[index] = f'{key}={value}' + updated = True + break +if not updated: + lines.append(f'{key}={value}') +temporary = Path('.env.tmp') +temporary.write_text('\n'.join(lines) + '\n') +os.chmod(temporary, 0o600) +temporary.replace(path) +PY +} +update_env LANGBOT_IMAGE_TAG "$TAG" +set -a +. ./.env +set +a + +docker compose pull postgres redis migrate plugin-runtime box core +docker compose up -d postgres redis +for _ in $(seq 1 60); do + if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi + sleep 2 +done +docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null + +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \ + -v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL' +SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec +ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; +GRANT CONNECT ON DATABASE langbot TO langbot_runtime; +GRANT USAGE, CREATE ON SCHEMA public TO langbot_runtime; +SQL + +docker compose --profile tools run --rm migrate + +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot <<'SQL' +GRANT USAGE ON SCHEMA public TO langbot_runtime; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO langbot_runtime; +GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO langbot_runtime; +SQL + +docker compose up -d --remove-orphans plugin-runtime box core +for _ in $(seq 1 90); do + if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then + docker compose ps + exit 0 + fi + sleep 2 +done +docker compose logs --tail=200 core plugin-runtime box >&2 +exit 1 diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 000000000..c5808c521 --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,157 @@ +services: + postgres: + image: pgvector/pgvector:pg17 + container_name: langbot-cloud-postgres + restart: unless-stopped + environment: + POSTGRES_DB: langbot + POSTGRES_USER: langbot_operator + POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"] + interval: 5s + timeout: 5s + retries: 30 + networks: [internal] + + redis: + image: redis:7.4-alpine + container_name: langbot-cloud-redis + restart: unless-stopped + command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"] + volumes: + - redis-data:/data + healthcheck: + test: [CMD-SHELL, "redis-cli -a '$${REDIS_PASSWORD}' ping | grep PONG"] + interval: 5s + timeout: 5s + retries: 20 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + networks: [internal] + + migrate: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + profiles: [tools] + command: [uv, run, langbot, migrate, --cloud] + environment: &core-env + TZ: Asia/Shanghai + SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID} + SYSTEM__EDITION: cloud + SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY} + SYSTEM__JWT__SECRET: ${JWT_SECRET} + SYSTEM__LIMITATION__MAX_BOTS: "2" + SYSTEM__LIMITATION__MAX_PIPELINES: "3" + SYSTEM__LIMITATION__MAX_EXTENSIONS: "3" + SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2" + API__WEBHOOK_PREFIX: https://cloud.langbot.app + API__WEBUI_URL: https://cloud.langbot.app + WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app + DATABASE__USE: postgresql + DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot + DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN + LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot + VDB__USE: pgvector + VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" + VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" + PLUGIN__ENABLE: "true" + PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws + PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws + PLUGIN__WORKER__MAX_CPUS: "0.25" + PLUGIN__WORKER__MAX_MEMORY_MB: "256" + PLUGIN__WORKER__MAX_PIDS: "128" + PLUGIN__WORKER__MAX_WORKERS: "16" + PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0" + PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096" + PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true" + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + BOX__ENABLED: "true" + BOX__BACKEND: nsjail + BOX__RUNTIME__ENDPOINT: ws://box:5410 + BOX__ADMISSION__REQUIRED: "true" + BOX__ADMISSION__LOGICAL_SESSION_ID: global + BOX__ADMISSION__REQUIRED_BACKEND: nsjail + BOX__ADMISSION__MAX_SESSIONS: "1" + BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0" + BOX__ADMISSION__CPUS: "0.25" + BOX__ADMISSION__MEMORY_MB: "256" + BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256" + BOX__LOCAL__HOST_ROOT: /app/data/box + BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + MCP__STDIO__ENABLED: "false" + LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app + LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} + LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY} + LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID} + SPACE__URL: https://space.langbot.app + depends_on: + postgres: {condition: service_healthy} + networks: [internal] + + plugin-runtime: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-plugin-runtime + restart: unless-stopped + command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt] + environment: + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + volumes: + - plugin-data:/app/data + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5400"] + networks: [internal] + + box: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-box + restart: unless-stopped + command: [uv, run, python, -m, langbot_box.cli, runtime, --host, 0.0.0.0, --port, "5410"] + environment: + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + LANGBOT_BOX_ROOT: /app/data/box + volumes: + - box-data:/app/data/box + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5410"] + networks: [internal] + + core: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-core + restart: unless-stopped + environment: *core-env + volumes: + - core-data:/app/data + - box-data:/app/data/box + depends_on: + postgres: {condition: service_healthy} + redis: {condition: service_healthy} + plugin-runtime: {condition: service_started} + box: {condition: service_started} + expose: ["5300"] + healthcheck: + test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + networks: [internal, shared-network] + +networks: + internal: + shared-network: + external: true + +volumes: + postgres-data: + redis-data: + plugin-data: + box-data: + core-data: From d5044c2f1e105dc75bc02ba03b35152f181c765b Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:08:58 +0000 Subject: [PATCH 02/33] fix(ci): authenticate cloud adapter checkout --- .github/workflows/deploy-prod.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index a23a9f239..4d39df099 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -43,6 +43,7 @@ jobs: with: repository: langbot-app/langbot-space ref: ${{ env.SPACE_REF }} + token: ${{ secrets.CLA_PAT }} path: .space - name: Build exact Cloud Core image uses: docker/build-push-action@v6 From 52c077280623b7bf800d483ec7fb0db53a7187e0 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:21:04 +0000 Subject: [PATCH 03/33] fix(cloud): pin Space production URL and deployment health --- deploy/prod/deploy.sh | 10 ++++++++++ deploy/prod/docker-compose.yml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh index 1c5f0917b..5bbf3b77e 100755 --- a/deploy/prod/deploy.sh +++ b/deploy/prod/deploy.sh @@ -6,6 +6,16 @@ TAG=${1:?usage: deploy.sh prod-<40-char-sha>} [[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; } [[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; } +rendered_compose=$(docker compose config) +grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud control-plane URL must be https://space.langbot.app' >&2 + exit 4 +} +grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2 + exit 5 +} + update_env() { local key=$1 value=$2 python3 - "$key" "$value" <<'PY' diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index c5808c521..c003d645f 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -24,7 +24,7 @@ services: volumes: - redis-data:/data healthcheck: - test: [CMD-SHELL, "redis-cli -a '$${REDIS_PASSWORD}' ping | grep PONG"] + test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] interval: 5s timeout: 5s retries: 20 @@ -55,7 +55,7 @@ services: LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot VDB__USE: pgvector VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" - VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" + VDB__PGVECTOR__ALLOWED_DIMENSIONS: "[384, 512, 768, 1024, 1536]" PLUGIN__ENABLE: "true" PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws From ace8cc67f29aebd990aa08a314ca02356a3ed45f Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:26:41 +0000 Subject: [PATCH 04/33] fix(config): preserve typed list environment overrides --- deploy/prod/docker-compose.yml | 2 +- src/langbot/pkg/core/stages/load_config.py | 11 ++++++++--- tests/unit_tests/core/test_load_config.py | 13 +++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index c003d645f..1adf5f1f2 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -55,7 +55,7 @@ services: LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot VDB__USE: pgvector VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" - VDB__PGVECTOR__ALLOWED_DIMENSIONS: "[384, 512, 768, 1024, 1536]" + VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" PLUGIN__ENABLE: "true" PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index a89cb0b48..49c439758 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -186,9 +186,14 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict: # At the final key if key in current: if isinstance(current[key], list): - # Convert comma-separated string to list - # e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk" - current[key] = [item.strip() for item in env_value.split(',') if item.strip()] + # Convert comma-separated values while preserving the + # element type declared by a non-empty config default. + items = [item.strip() for item in env_value.split(',') if item.strip()] + if current[key]: + exemplar = current[key][0] + current[key] = [convert_value(item, exemplar) for item in items] + else: + current[key] = items elif isinstance(current[key], dict): # Skip dict types pass diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index b95bc25ba..defbcee9d 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -152,6 +152,19 @@ class TestApplyEnvOverridesToConfig: assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram'] + def test_override_integer_list_preserves_item_type(self): + """Comma-separated overrides inherit the existing list item type.""" + load_config = get_load_config_module() + + cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}} + env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'} + + with patch.dict(os.environ, env, clear=True): + result = load_config._apply_env_overrides_to_config(cfg) + + assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768] + assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions']) + def test_override_list_value_empty_items(self): """Test that empty items in comma-separated list are filtered.""" load_config = get_load_config_module() From 122d8fa65924740953642dc272243867d8e991fe Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:34:20 +0000 Subject: [PATCH 05/33] fix(deploy): retry transient image pull failures --- deploy/prod/deploy.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh index 5bbf3b77e..3916a2c17 100755 --- a/deploy/prod/deploy.sh +++ b/deploy/prod/deploy.sh @@ -45,7 +45,18 @@ set -a . ./.env set +a -docker compose pull postgres redis migrate plugin-runtime box core +for attempt in 1 2 3 4 5; do + if docker compose pull postgres redis migrate plugin-runtime box core; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "docker compose pull failed after $attempt attempts" >&2 + exit 1 + fi + delay=$((attempt * 10)) + echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2 + sleep "$delay" +done docker compose up -d postgres redis for _ in $(seq 1 60); do if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi From a0b85e11fd5c19164d6c9e6fc09577f48d889f5c Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:44:57 +0000 Subject: [PATCH 06/33] fix(deploy): keep runtime role schema read-only --- deploy/prod/deploy.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh index 3916a2c17..fe9dd86f5 100755 --- a/deploy/prod/deploy.sh +++ b/deploy/prod/deploy.sh @@ -70,7 +70,8 @@ SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_passwor WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; GRANT CONNECT ON DATABASE langbot TO langbot_runtime; -GRANT USAGE, CREATE ON SCHEMA public TO langbot_runtime; +REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime; +GRANT USAGE ON SCHEMA public TO langbot_runtime; SQL docker compose --profile tools run --rm migrate From 6d2e9d3d721257087e4269800da0f60040c6bf77 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:48:51 +0000 Subject: [PATCH 07/33] fix(deploy): do not start disabled Box runtime --- deploy/prod/deploy.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh index fe9dd86f5..792b2d63b 100755 --- a/deploy/prod/deploy.sh +++ b/deploy/prod/deploy.sh @@ -46,7 +46,7 @@ set -a set +a for attempt in 1 2 3 4 5; do - if docker compose pull postgres redis migrate plugin-runtime box core; then + if docker compose pull postgres redis migrate plugin-runtime core; then break fi if [ "$attempt" -eq 5 ]; then @@ -84,7 +84,7 @@ ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT SELECT ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO langbot_runtime; SQL -docker compose up -d --remove-orphans plugin-runtime box core +docker compose up -d --remove-orphans plugin-runtime core for _ in $(seq 1 90); do if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then docker compose ps @@ -92,5 +92,5 @@ for _ in $(seq 1 90); do fi sleep 2 done -docker compose logs --tail=200 core plugin-runtime box >&2 +docker compose logs --tail=200 core plugin-runtime >&2 exit 1 From 9066c2572950024f00a1465383bf5b0aa0fe1f90 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:58:31 +0000 Subject: [PATCH 08/33] fix(deploy): let migration own exact runtime ACLs --- deploy/prod/deploy.sh | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh index 792b2d63b..f434a7403 100755 --- a/deploy/prod/deploy.sh +++ b/deploy/prod/deploy.sh @@ -71,19 +71,15 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexe ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; GRANT CONNECT ON DATABASE langbot TO langbot_runtime; REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime; GRANT USAGE ON SCHEMA public TO langbot_runtime; SQL docker compose --profile tools run --rm migrate -docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot <<'SQL' -GRANT USAGE ON SCHEMA public TO langbot_runtime; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO langbot_runtime; -GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO langbot_runtime; -ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO langbot_runtime; -ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO langbot_runtime; -SQL - docker compose up -d --remove-orphans plugin-runtime core for _ in $(seq 1 90); do if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then From dd95545309201be365fd2deaa4e404318652ec92 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:17:17 +0000 Subject: [PATCH 09/33] fix(prod): configure shared Box runtime --- deploy/prod/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 1adf5f1f2..6a2b74bc7 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -79,6 +79,7 @@ services: BOX__ADMISSION__MEMORY_MB: "256" BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256" BOX__LOCAL__HOST_ROOT: /app/data/box + BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} MCP__STDIO__ENABLED: "false" @@ -110,7 +111,7 @@ services: image: rockchin/langbot:${LANGBOT_IMAGE_TAG} container_name: langbot-cloud-box restart: unless-stopped - command: [uv, run, python, -m, langbot_box.cli, runtime, --host, 0.0.0.0, --port, "5410"] + command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"] environment: LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} LANGBOT_BOX_ROOT: /app/data/box From d155d9d5a83e5d907f648d1db674792c2329cfe1 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:42:49 +0000 Subject: [PATCH 10/33] fix(cloud): allow explicitly disabled box runtime --- deploy/prod/docker-compose.yml | 5 ++++- src/langbot/pkg/cloud/bootstrap.py | 10 ++++++++-- tests/unit_tests/cloud/test_bootstrap.py | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 6a2b74bc7..d46139243 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -67,7 +67,10 @@ services: PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096" PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true" LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} - BOX__ENABLED: "true" + # Cloud v2 currently grants no managed Box capability. Keep the shared + # runtime deployed but disable Core integration until a hard-quota-capable + # backend can satisfy the fail-closed Cloud readiness contract. + BOX__ENABLED: "false" BOX__BACKEND: nsjail BOX__RUNTIME__ENDPOINT: ws://box:5410 BOX__ADMISSION__REQUIRED: "true" diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py index 8e9ee1886..c8341e56c 100644 --- a/src/langbot/pkg/cloud/bootstrap.py +++ b/src/langbot/pkg/cloud/bootstrap.py @@ -138,8 +138,14 @@ class VerifiedCloudDeployment: if plugin_worker.get('require_hard_limits') is not True: raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true') box_config = config.get('box', {}) - if box_config.get('enabled') is not True: - raise CloudBootstrapError('Cloud runtime requires box.enabled=true') + box_enabled = box_config.get('enabled') + if box_enabled is False: + # Explicitly disabling Box removes the sandbox surface entirely and + # therefore does not weaken tenant isolation. Validate the strict + # runtime/admission contract only when the surface is enabled. + return + if box_enabled is not True: + raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean') if box_config.get('backend') != 'nsjail': raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail') runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip() diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py index 77ccefa5d..e27367d8d 100644 --- a/tests/unit_tests/cloud/test_bootstrap.py +++ b/tests/unit_tests/cloud/test_bootstrap.py @@ -228,10 +228,23 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message): ) +async def test_cloud_runtime_allows_explicitly_disabled_box(): + config = _cloud_config() + config['box']['enabled'] = False + + deployment = await resolve_deployment( + instance_uuid='instance-a', + instance_config=config, + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]), + now=1_000, + ) + + assert isinstance(deployment, VerifiedCloudDeployment) + + @pytest.mark.parametrize( ('mutate', 'message'), [ - (lambda config: config['box'].update(enabled=False), 'box.enabled=true'), (lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'), (lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'), ( From 88f328066b2221a67f7aa5dbdfdc406d3aa37950 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:41:23 +0000 Subject: [PATCH 11/33] fix(cloud): keep runtime sdk ahead of plugin dependencies --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1e588fa8..7ff13f8a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162", + "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@c0a53dca56d2e69d9e226e361a431c96c1d3dfd8", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index a23277e25..9eeee60e1 100644 --- a/uv.lock +++ b/uv.lock @@ -2116,7 +2116,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c0a53dca56d2e69d9e226e361a431c96c1d3dfd8" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2183,7 +2183,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.4.18" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162#1d65ed301a6afc52150a998043f73cd6032c8162" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c0a53dca56d2e69d9e226e361a431c96c1d3dfd8#c0a53dca56d2e69d9e226e361a431c96c1d3dfd8" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, From 59db01259461c36798cc618b5bcba01121979fd7 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:48:42 +0000 Subject: [PATCH 12/33] feat(cloud): enforce workspace resource quotas --- src/langbot/pkg/api/http/controller/group.py | 3 + src/langbot/pkg/api/http/service/bot.py | 36 +++- src/langbot/pkg/cloud/quotas.py | 82 ++++++++ src/langbot/pkg/plugin/connector.py | 22 ++ .../test_workspace_quota_postgres.py | 137 +++++++++++++ .../api/http/test_internal_error_responses.py | 25 +++ .../api/service/test_bot_service.py | 7 +- .../test_cloud_bot_quota_concurrency.py | 129 ++++++++++++ .../unit_tests/cloud/test_workspace_quotas.py | 102 ++++++++++ .../test_cloud_plugin_quota_concurrency.py | 191 ++++++++++++++++++ 10 files changed, 722 insertions(+), 12 deletions(-) create mode 100644 src/langbot/pkg/cloud/quotas.py create mode 100644 tests/integration/persistence/test_workspace_quota_postgres.py create mode 100644 tests/unit_tests/api/service/test_cloud_bot_quota_concurrency.py create mode 100644 tests/unit_tests/cloud/test_workspace_quotas.py create mode 100644 tests/unit_tests/plugin/test_cloud_plugin_quota_concurrency.py diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 1fc09a0b2..1d229772a 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -14,6 +14,7 @@ from ....utils import bounded_executor from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError +from ....cloud.quotas import WorkspaceQuotaExceededError from ....core.errors import TaskCapacityError from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext @@ -219,6 +220,8 @@ class RouterGroup(abc.ABC): return self.http_status(403, e.code, str(e)) if isinstance(e, WorkspaceCollaborationError): return self.http_status(400, e.code, str(e)) + if isinstance(e, WorkspaceQuotaExceededError): + return self.http_status(409, e.error_code, str(e)) if isinstance(e, TaskCapacityError): return self.http_status(429, 'task_capacity_exceeded', str(e)) if isinstance( diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 9d9211be3..7e8080060 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -4,6 +4,7 @@ import uuid import sqlalchemy from ....core import app +from ....cloud.quotas import require_resource_capacity, resolve_workspace_quota from ....entity.persistence import bot as persistence_bot from ....entity.persistence import pipeline as persistence_pipeline from ....workspace.errors import WorkspaceNotFoundError @@ -101,20 +102,21 @@ class BotService: async def create_bot(self, context: TenantContext, bot_data: dict) -> str: """Create bot""" workspace_uuid = require_workspace_uuid(context) - # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) - max_bots = limitation.get('max_bots', -1) - if max_bots >= 0: - existing_bots = await self.get_bots(context) - if len(existing_bots) >= max_bots: - raise ValueError(f'Maximum number of bots ({max_bots}) reached') + quota = await resolve_workspace_quota( + self.ap, + workspace_uuid, + 'bots.max', + fallback=limitation.get('max_bots', -1), + ) # TODO: 检查配置信息格式 bot_data = bot_data.copy() bot_data['uuid'] = str(uuid.uuid4()) bot_data['workspace_uuid'] = workspace_uuid - # bind the most recently updated pipeline if any exist + # Preserve the legacy flat-row result shape for this optional lookup; + # quota admission and insertion below still share one transaction. result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.select(persistence_pipeline.LegacyPipeline), @@ -129,7 +131,25 @@ class BotService: bot_data['use_pipeline_uuid'] = pipeline.uuid bot_data['use_pipeline_name'] = pipeline.name - await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) + async def persist(execute) -> None: + await require_resource_capacity( + execute, + workspace_uuid=workspace_uuid, + model=persistence_bot.Bot, + quota=quota, + resource_name='bots', + ) + + await execute(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) + + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if quota.requires_transaction_lock: + if not callable(tenant_uow): + raise RuntimeError('Cloud bot quota enforcement requires transactional persistence') + async with tenant_uow(workspace_uuid) as uow: + await persist(uow.execute) + else: + await persist(self.ap.persistence_mgr.execute_async) bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) diff --git a/src/langbot/pkg/cloud/quotas.py b/src/langbot/pkg/cloud/quotas.py new file mode 100644 index 000000000..d5229d0b2 --- /dev/null +++ b/src/langbot/pkg/cloud/quotas.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +import sqlalchemy + +from ..entity.persistence import workspace as persistence_workspace +from .entitlements import EntitlementResolver + + +Execute = Callable[[Any], Awaitable[Any]] + + +class WorkspaceQuotaExceededError(ValueError): + """A stable business error raised when a workspace has no free slots.""" + + error_code = 'workspace_quota_exceeded' + + def __init__(self, resource_name: str, limit: int) -> None: + self.resource_name = resource_name + self.limit = limit + super().__init__(f'Maximum number of {resource_name} ({limit}) reached') + + +@dataclass(frozen=True, slots=True) +class WorkspaceQuota: + limit: int + requires_transaction_lock: bool + + +async def resolve_workspace_quota( + ap: Any, + workspace_uuid: str, + limit_name: str, + *, + fallback: int = -1, +) -> WorkspaceQuota: + """Resolve a plan-agnostic Cloud limit while preserving OSS configuration.""" + + resolver = getattr(ap, 'entitlement_resolver', None) + if isinstance(resolver, EntitlementResolver): + snapshot = await resolver.resolve(workspace_uuid) + return WorkspaceQuota( + limit=snapshot.limit(limit_name), + requires_transaction_lock=True, + ) + return WorkspaceQuota(limit=fallback, requires_transaction_lock=False) + + +async def lock_workspace_for_quota(execute: Execute, workspace_uuid: str) -> None: + """Serialize quota checks on the durable Workspace row within one transaction.""" + + result = await execute( + sqlalchemy.select(persistence_workspace.Workspace.uuid) + .where(persistence_workspace.Workspace.uuid == workspace_uuid) + .with_for_update() + ) + if result.first() is None: + raise ValueError('Workspace does not exist') + + +async def require_resource_capacity( + execute: Execute, + *, + workspace_uuid: str, + model: type, + quota: WorkspaceQuota, + resource_name: str, + workspace_locked: bool = False, +) -> None: + if quota.limit < 0: + return + if quota.requires_transaction_lock and not workspace_locked: + await lock_workspace_for_quota(execute, workspace_uuid) + result = await execute( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(model) + .where(model.workspace_uuid == workspace_uuid) + ) + if int(result.scalar_one()) >= quota.limit: + raise WorkspaceQuotaExceededError(resource_name, quota.limit) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index b82b79427..cbfb4f6a2 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -19,6 +19,11 @@ from urllib.parse import urljoin, urlparse from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from ..core import app +from ..cloud.quotas import ( + lock_workspace_for_quota, + require_resource_capacity, + resolve_workspace_quota, +) from . import handler from .archive import inspect_plugin_archive_metadata from .github import ( @@ -1295,6 +1300,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): install_info: dict[str, Any], artifact_digest: str, ) -> tuple[InstallationBinding, str | None, bool]: + quota = await resolve_workspace_quota( + self.ap, + execution_context.workspace_uuid, + 'plugins.max', + ) safe_install_info = { key: value for key, value in install_info.items() @@ -1316,9 +1326,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ) async def persist(execute): + if quota.requires_transaction_lock: + await lock_workspace_for_quota(execute, execution_context.workspace_uuid) result = await execute(statement) setting = result.first() if setting is None: + await require_resource_capacity( + execute, + workspace_uuid=execution_context.workspace_uuid, + model=persistence_plugin.PluginSetting, + quota=quota, + resource_name='plugins', + workspace_locked=quota.requires_transaction_lock, + ) installation_uuid = str(uuid.uuid4()) runtime_revision = 1 previous_digest = None @@ -1373,6 +1393,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ) tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if quota.requires_transaction_lock and not callable(tenant_uow): + raise RuntimeError('Cloud plugin quota enforcement requires transactional persistence') if callable(tenant_uow): async with tenant_uow(execution_context.workspace_uuid) as uow: return await persist(uow.execute) diff --git a/tests/integration/persistence/test_workspace_quota_postgres.py b/tests/integration/persistence/test_workspace_quota_postgres.py new file mode 100644 index 000000000..b83d089fa --- /dev/null +++ b/tests/integration/persistence/test_workspace_quota_postgres.py @@ -0,0 +1,137 @@ +"""PostgreSQL integration coverage for durable workspace quota locking. + +Run with TEST_POSTGRES_URL=postgresql+asyncpg://... pytest ... +""" + +from __future__ import annotations + +import asyncio +import os +import uuid + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from langbot.pkg.cloud import quotas as quota_module +from langbot.pkg.cloud.quotas import WorkspaceQuota, WorkspaceQuotaExceededError, require_resource_capacity + + +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] + + +class _Base(DeclarativeBase): + pass + + +class _Workspace(_Base): + __tablename__ = 'quota_integration_workspaces' + + uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True) + + +class _Resource(_Base): + __tablename__ = 'quota_integration_resources' + + uuid: Mapped[str] = mapped_column(sa.String(36), primary_key=True) + workspace_uuid: Mapped[str] = mapped_column( + sa.String(36), + sa.ForeignKey('quota_integration_workspaces.uuid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + + +@pytest.fixture +async def quota_postgres(monkeypatch): + url = os.environ.get('TEST_POSTGRES_URL') + if not url: + pytest.skip('TEST_POSTGRES_URL not set') + if url.startswith('postgresql://'): + url = url.replace('postgresql://', 'postgresql+asyncpg://', 1) + + engine = create_async_engine(url, pool_size=5, max_overflow=0) + monkeypatch.setattr(quota_module.persistence_workspace, 'Workspace', _Workspace) + async with engine.begin() as connection: + await connection.run_sync(_Base.metadata.drop_all) + await connection.run_sync(_Base.metadata.create_all) + try: + yield url, engine + finally: + async with engine.begin() as connection: + await connection.run_sync(_Base.metadata.drop_all) + await engine.dispose() + + +async def test_workspace_row_lock_is_atomic_isolated_and_survives_pool_restart(quota_postgres) -> None: + url, engine = quota_postgres + workspace_a = str(uuid.uuid4()) + workspace_b = str(uuid.uuid4()) + quota = WorkspaceQuota(limit=1, requires_transaction_lock=True) + sessions = async_sessionmaker(engine, expire_on_commit=False) + + async with engine.begin() as connection: + await connection.execute(sa.insert(_Workspace), [{'uuid': workspace_a}, {'uuid': workspace_b}]) + + lock_acquired = asyncio.Event() + release_first = asyncio.Event() + + async def admit(workspace_uuid: str, *, hold: bool = False) -> None: + async with sessions() as session: + async with session.begin(): + await require_resource_capacity( + session.execute, + workspace_uuid=workspace_uuid, + model=_Resource, + quota=quota, + resource_name='resources', + ) + if hold: + lock_acquired.set() + await release_first.wait() + await session.execute( + sa.insert(_Resource).values(uuid=str(uuid.uuid4()), workspace_uuid=workspace_uuid) + ) + + first = asyncio.create_task(admit(workspace_a, hold=True)) + await asyncio.wait_for(lock_acquired.wait(), timeout=2) + same_workspace = asyncio.create_task(admit(workspace_a)) + other_workspace = asyncio.create_task(admit(workspace_b)) + + await asyncio.wait_for(other_workspace, timeout=2) + assert not same_workspace.done(), 'same-workspace transaction bypassed SELECT FOR UPDATE' + + release_first.set() + await first + with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of resources \(1\) reached'): + await same_workspace + + async with sessions() as session: + counts = dict( + ( + await session.execute( + sa.select(_Resource.workspace_uuid, sa.func.count()) + .group_by(_Resource.workspace_uuid) + .order_by(_Resource.workspace_uuid) + ) + ).all() + ) + assert counts == {workspace_a: 1, workspace_b: 1} + + await engine.dispose() + restarted_engine = create_async_engine(url, pool_size=2, max_overflow=0) + restarted_sessions = async_sessionmaker(restarted_engine, expire_on_commit=False) + try: + async with restarted_sessions() as session: + async with session.begin(): + with pytest.raises(WorkspaceQuotaExceededError): + await require_resource_capacity( + session.execute, + workspace_uuid=workspace_a, + model=_Resource, + quota=quota, + resource_name='resources', + ) + finally: + await restarted_engine.dispose() diff --git a/tests/unit_tests/api/http/test_internal_error_responses.py b/tests/unit_tests/api/http/test_internal_error_responses.py index 25c0e83ef..96fc024ec 100644 --- a/tests/unit_tests/api/http/test_internal_error_responses.py +++ b/tests/unit_tests/api/http/test_internal_error_responses.py @@ -9,6 +9,7 @@ import quart from langbot.pkg.api.http.controller import group from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup +from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError from langbot.pkg.utils.bounded_executor import ( BlockingWorkCapacityError, current_blocking_work_scope, @@ -48,6 +49,16 @@ class _BlockingCapacityRouterGroup(group.RouterGroup): raise BlockingWorkCapacityError('Workspace blocking executor capacity reached') +class _QuotaRouterGroup(group.RouterGroup): + name = 'quota-test' + path = '/quota-test' + + async def initialize(self) -> None: + @self.route('', methods=['POST'], auth_type=group.AuthType.NONE) + async def _(): + raise WorkspaceQuotaExceededError('bots', 2) + + class _InvalidAccountRouterGroup(group.RouterGroup): name = 'invalid-account-test' path = '/invalid-account-test' @@ -130,6 +141,20 @@ async def test_blocking_work_capacity_maps_to_retryable_http_response(): } +async def test_workspace_quota_maps_to_stable_conflict_response(): + application = SimpleNamespace(logger=Mock()) + quart_app = quart.Quart(__name__) + await _QuotaRouterGroup(application, quart_app).initialize() + + response = await quart_app.test_client().post('/quota-test') + + assert response.status_code == 409 + assert await response.get_json() == { + 'code': 'workspace_quota_exceeded', + 'msg': 'Maximum number of bots (2) reached', + } + + async def test_public_webhook_carries_scope_without_holding_database_session(): class ScopeOnlyPersistenceManager: mode = SimpleNamespace(value='cloud_runtime') diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index dea5763f2..43621b480 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -311,10 +311,9 @@ class TestBotServiceCreateBot: ap.platform_mgr = SimpleNamespace() ap.platform_mgr.load_bot = AsyncMock() - # Mock get_bots to return 2 bots already - bot1 = _create_mock_bot(bot_uuid='uuid-1') - bot2 = _create_mock_bot(bot_uuid='uuid-2') - mock_result = _create_mock_result([bot1, bot2]) + # Mock the atomic count query to report 2 existing bots. + mock_result = _create_mock_result() + mock_result.scalar_one = Mock(return_value=2) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'}) diff --git a/tests/unit_tests/api/service/test_cloud_bot_quota_concurrency.py b/tests/unit_tests/api/service/test_cloud_bot_quota_concurrency.py new file mode 100644 index 000000000..78eab3ac2 --- /dev/null +++ b/tests/unit_tests/api/service/test_cloud_bot_quota_concurrency.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy + +from langbot.pkg.api.http.service.bot import BotService +from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot + + +INSTANCE_UUID = 'cloud-instance' +WORKSPACE_A = '11111111-1111-1111-1111-111111111111' +WORKSPACE_B = '22222222-2222-2222-2222-222222222222' + + +class _Provider: + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + return EntitlementSnapshot( + instance_uuid=INSTANCE_UUID, + workspace_uuid=workspace_uuid, + entitlement_revision=1, + status='active', + not_before=0, + expires_at=4_102_444_800, + features={}, + limits={'bots.max': 2}, + ) + + +class _Result: + def __init__(self, *, first=None, scalar=None) -> None: + self._first = first + self._scalar = scalar + + def first(self): + return self._first + + def scalar_one(self): + return self._scalar + + +class _TenantUow: + def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None: + self.manager = manager + self.workspace_uuid = workspace_uuid + self.lock = manager.locks[workspace_uuid] + + async def __aenter__(self): + await self.lock.acquire() + return self + + async def __aexit__(self, exc_type, exc, tb): + self.lock.release() + + async def execute(self, statement): + sql = str(statement) + if isinstance(statement, sqlalchemy.sql.dml.Insert): + assert statement.table.name == 'bots' + self.manager.bots[self.workspace_uuid].append(statement.compile().params) + return _Result() + if 'FROM workspaces' in sql: + assert statement._for_update_arg is not None + self.manager.workspace_locks_seen += 1 + return _Result(first=(self.workspace_uuid,)) + if 'count(' in sql.lower() and 'FROM bots' in sql: + return _Result(scalar=len(self.manager.bots[self.workspace_uuid])) + if 'FROM legacy_pipelines' in sql: + return _Result(first=None) + raise AssertionError(f'unexpected statement: {sql}') + + +class _Persistence: + def __init__(self) -> None: + self.locks = defaultdict(asyncio.Lock) + self.bots = defaultdict(list) + self.workspace_locks_seen = 0 + + def tenant_uow(self, workspace_uuid: str) -> _TenantUow: + return _TenantUow(self, workspace_uuid) + + async def execute_async(self, statement): + assert 'FROM legacy_pipelines' in str(statement) + return _Result(first=None) + + +async def _service(manager: _Persistence) -> BotService: + resolver = EntitlementResolver(INSTANCE_UUID, _Provider()) + await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B}) + ap = SimpleNamespace( + entitlement_resolver=resolver, + persistence_mgr=manager, + instance_config=SimpleNamespace(data={'system': {'limitation': {'max_bots': 99}}}), + platform_mgr=SimpleNamespace(load_bot=AsyncMock()), + ) + service = BotService(ap) + service.get_bot = AsyncMock(return_value={'uuid': 'created'}) + return service + + +@pytest.mark.asyncio +async def test_cloud_bot_quota_is_atomic_isolated_and_persists_across_service_restart() -> None: + manager = _Persistence() + service = await _service(manager) + + async def create(workspace_uuid: str, index: int): + return await service.create_bot(workspace_uuid, {'name': f'bot-{index}'}) + + results = await asyncio.gather( + *(create(WORKSPACE_A, index) for index in range(8)), + *(create(WORKSPACE_B, index) for index in range(8)), + return_exceptions=True, + ) + + successes = [result for result in results if isinstance(result, str)] + failures = [result for result in results if isinstance(result, ValueError)] + assert len(successes) == 4 + assert len(failures) == 12 + assert len(manager.bots[WORKSPACE_A]) == 2 + assert len(manager.bots[WORKSPACE_B]) == 2 + assert manager.workspace_locks_seen == 16 + + restarted_service = await _service(manager) + with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'): + await restarted_service.create_bot(WORKSPACE_A, {'name': 'after-restart'}) + assert len(manager.bots[WORKSPACE_A]) == 2 diff --git a/tests/unit_tests/cloud/test_workspace_quotas.py b/tests/unit_tests/cloud/test_workspace_quotas.py new file mode 100644 index 000000000..58734aeeb --- /dev/null +++ b/tests/unit_tests/cloud/test_workspace_quotas.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy + +from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot +from langbot.pkg.cloud.quotas import ( + WorkspaceQuota, + require_resource_capacity, + resolve_workspace_quota, +) +from langbot.pkg.entity.persistence.bot import Bot + + +WORKSPACE_UUID = '11111111-1111-1111-1111-111111111111' +INSTANCE_UUID = 'cloud-instance' + + +class _Provider: + def __init__(self, limits: dict[str, int]) -> None: + self.limits = limits + + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + return EntitlementSnapshot( + instance_uuid=INSTANCE_UUID, + workspace_uuid=workspace_uuid, + entitlement_revision=1, + status='active', + not_before=0, + expires_at=4_102_444_800, + features={}, + limits=self.limits, + plan_name='test', + ) + + +async def _resolver(limits: dict[str, int]) -> EntitlementResolver: + resolver = EntitlementResolver(INSTANCE_UUID, _Provider(limits)) + await resolver.reconcile_active_workspaces({WORKSPACE_UUID}) + return resolver + + +@pytest.mark.asyncio +async def test_resolve_workspace_quota_uses_signed_cloud_limit() -> None: + ap = SimpleNamespace(entitlement_resolver=await _resolver({'bots.max': 2})) + + quota = await resolve_workspace_quota(ap, WORKSPACE_UUID, 'bots.max', fallback=99) + + assert quota == WorkspaceQuota(limit=2, requires_transaction_lock=True) + + +@pytest.mark.asyncio +async def test_resolve_workspace_quota_preserves_oss_fallback() -> None: + quota = await resolve_workspace_quota(SimpleNamespace(), WORKSPACE_UUID, 'bots.max', fallback=7) + + assert quota == WorkspaceQuota(limit=7, requires_transaction_lock=False) + + +@pytest.mark.asyncio +async def test_require_resource_capacity_locks_workspace_before_counting() -> None: + statements: list[object] = [] + lock_result = Mock() + lock_result.first.return_value = (WORKSPACE_UUID,) + count_result = Mock() + count_result.scalar_one.return_value = 1 + execute = AsyncMock(side_effect=[lock_result, count_result]) + + await require_resource_capacity( + execute, + workspace_uuid=WORKSPACE_UUID, + model=Bot, + quota=WorkspaceQuota(limit=2, requires_transaction_lock=True), + resource_name='bots', + ) + + statements.extend(call.args[0] for call in execute.await_args_list) + assert len(statements) == 2 + assert isinstance(statements[0], sqlalchemy.sql.Select) + assert statements[0]._for_update_arg is not None + assert 'workspaces' in str(statements[0]) + assert 'count' in str(statements[1]).lower() + + +@pytest.mark.asyncio +async def test_require_resource_capacity_rejects_at_boundary() -> None: + lock_result = Mock() + lock_result.first.return_value = (WORKSPACE_UUID,) + count_result = Mock() + count_result.scalar_one.return_value = 2 + execute = AsyncMock(side_effect=[lock_result, count_result]) + + with pytest.raises(ValueError, match=r'Maximum number of bots \(2\) reached'): + await require_resource_capacity( + execute, + workspace_uuid=WORKSPACE_UUID, + model=Bot, + quota=WorkspaceQuota(limit=2, requires_transaction_lock=True), + resource_name='bots', + ) diff --git a/tests/unit_tests/plugin/test_cloud_plugin_quota_concurrency.py b/tests/unit_tests/plugin/test_cloud_plugin_quota_concurrency.py new file mode 100644 index 000000000..18dfd52a6 --- /dev/null +++ b/tests/unit_tests/plugin/test_cloud_plugin_quota_concurrency.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from types import SimpleNamespace + +import pytest +import sqlalchemy + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot +from langbot.pkg.cloud.quotas import WorkspaceQuotaExceededError +from langbot.pkg.plugin.connector import PluginRuntimeConnector +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + + +INSTANCE_UUID = 'cloud-instance' +WORKSPACE_A = '11111111-1111-1111-1111-111111111111' +WORKSPACE_B = '22222222-2222-2222-2222-222222222222' + + +class _Provider: + async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot: + return EntitlementSnapshot( + instance_uuid=INSTANCE_UUID, + workspace_uuid=workspace_uuid, + entitlement_revision=1, + status='active', + not_before=0, + expires_at=4_102_444_800, + features={}, + limits={'plugins.max': 3}, + ) + + +class _Result: + def __init__(self, *, first=None, scalar=None) -> None: + self._first = first + self._scalar = scalar + + def first(self): + return self._first + + def scalar_one(self): + return self._scalar + + +class _TenantUow: + def __init__(self, manager: '_Persistence', workspace_uuid: str) -> None: + self.manager = manager + self.workspace_uuid = workspace_uuid + self.lock = manager.locks[workspace_uuid] + + async def __aenter__(self): + await self.lock.acquire() + return self + + async def __aexit__(self, exc_type, exc, tb): + self.lock.release() + + async def execute(self, statement): + sql = str(statement) + params = statement.compile().params + if isinstance(statement, sqlalchemy.sql.dml.Insert): + assert statement.table.name == 'plugin_settings' + key = (params['plugin_author'], params['plugin_name']) + self.manager.plugins[self.workspace_uuid][key] = dict(params) + return _Result() + if isinstance(statement, sqlalchemy.sql.dml.Update): + return _Result() + if 'FROM workspaces' in sql: + assert statement._for_update_arg is not None + self.manager.workspace_locks_seen += 1 + return _Result(first=(self.workspace_uuid,)) + if 'count(' in sql.lower() and 'FROM plugin_settings' in sql: + return _Result(scalar=len(self.manager.plugins[self.workspace_uuid])) + if 'FROM plugin_settings' in sql: + author = next(value for name, value in params.items() if 'plugin_author' in name) + name = next(value for param, value in params.items() if 'plugin_name' in param) + row = self.manager.plugins[self.workspace_uuid].get((author, name)) + if row is None: + return _Result(first=None) + return _Result( + first=SimpleNamespace( + installation_uuid=row['installation_uuid'], + runtime_revision=row['runtime_revision'], + artifact_digest=row['artifact_digest'], + install_info=row['install_info'], + ) + ) + raise AssertionError(f'unexpected statement: {sql}') + + +class _Persistence: + def __init__(self) -> None: + self.locks = defaultdict(asyncio.Lock) + self.plugins = defaultdict(dict) + self.workspace_locks_seen = 0 + + def tenant_uow(self, workspace_uuid: str) -> _TenantUow: + return _TenantUow(self, workspace_uuid) + + +async def _connector(manager: _Persistence) -> PluginRuntimeConnector: + resolver = EntitlementResolver(INSTANCE_UUID, _Provider()) + await resolver.reconcile_active_workspaces({WORKSPACE_A, WORKSPACE_B}) + connector = object.__new__(PluginRuntimeConnector) + connector.ap = SimpleNamespace(entitlement_resolver=resolver, persistence_mgr=manager) + return connector + + +def _context(workspace_uuid: str) -> ExecutionContext: + return ExecutionContext( + instance_uuid=INSTANCE_UUID, + workspace_uuid=workspace_uuid, + placement_generation=1, + entitlement_revision=1, + ) + + +@pytest.mark.asyncio +async def test_cloud_plugin_quota_is_atomic_isolated_and_persists_across_connector_restart() -> None: + manager = _Persistence() + connector = await _connector(manager) + + async def install(workspace_uuid: str, index: int): + return await connector._persist_installation_package( + _context(workspace_uuid), + plugin_author='test-author', + plugin_name=f'plugin-{index}', + install_source=PluginInstallSource.MARKETPLACE, + install_info={'author': 'test-author', 'name': f'plugin-{index}'}, + artifact_digest=f'{index:064x}', + ) + + results = await asyncio.gather( + *(install(WORKSPACE_A, index) for index in range(10)), + *(install(WORKSPACE_B, index) for index in range(10)), + return_exceptions=True, + ) + + successes = [result for result in results if isinstance(result, tuple)] + failures = [result for result in results if isinstance(result, WorkspaceQuotaExceededError)] + assert len(successes) == 6 + assert len(failures) == 14 + assert len(manager.plugins[WORKSPACE_A]) == 3 + assert len(manager.plugins[WORKSPACE_B]) == 3 + assert manager.workspace_locks_seen == 20 + + restarted_connector = await _connector(manager) + with pytest.raises(WorkspaceQuotaExceededError, match=r'Maximum number of plugins \(3\) reached'): + await restarted_connector._persist_installation_package( + _context(WORKSPACE_A), + plugin_author='test-author', + plugin_name='after-restart', + install_source=PluginInstallSource.MARKETPLACE, + install_info={}, + artifact_digest='f' * 64, + ) + assert len(manager.plugins[WORKSPACE_A]) == 3 + + installed_name = next(iter(manager.plugins[WORKSPACE_A]))[1] + + async def reinstall(): + return await restarted_connector._persist_installation_package( + _context(WORKSPACE_A), + plugin_author='test-author', + plugin_name=installed_name, + install_source=PluginInstallSource.MARKETPLACE, + install_info={'author': 'test-author', 'name': installed_name, 'revision': 2}, + artifact_digest='e' * 64, + ) + + reinstall_results = await asyncio.gather(reinstall(), reinstall()) + assert all(result[2] is True for result in reinstall_results) + + mixed_results = await asyncio.gather( + reinstall(), + restarted_connector._persist_installation_package( + _context(WORKSPACE_A), + plugin_author='test-author', + plugin_name='new-at-capacity', + install_source=PluginInstallSource.MARKETPLACE, + install_info={}, + artifact_digest='d' * 64, + ), + return_exceptions=True, + ) + assert isinstance(mixed_results[0], tuple) + assert isinstance(mixed_results[1], WorkspaceQuotaExceededError) + assert len(manager.plugins[WORKSPACE_A]) == 3 From 92d9db8f95e3fa195342f0969fb78d7c18b3da83 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:05:17 +0000 Subject: [PATCH 13/33] chore(plugin): pin SDK 0.5.0 --- docs/multi-tenant/implementation-decisions.md | 6 +++--- pyproject.toml | 2 +- uv.lock | 10 +++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/multi-tenant/implementation-decisions.md b/docs/multi-tenant/implementation-decisions.md index a74c13f32..60a28feab 100644 --- a/docs/multi-tenant/implementation-decisions.md +++ b/docs/multi-tenant/implementation-decisions.md @@ -81,10 +81,10 @@ This log records implementation choices made while delivering the Workspace arch - Decision: The MCP ASGI mount authenticates the API key once, binds an immutable per-request `RequestContext`, and every tool checks a fixed permission before calling tenant services with that same context. - Reason: Authenticating the transport without propagating Workspace identity into tool calls would leave the direct service path globally scoped. -### Unreleased SDK protocol is pinned reproducibly without publishing +### Released SDK protocol is pinned from PyPI -- Decision: The SDK tenancy protocol is versioned as 0.4.18. This task does not create a GitHub release or publish PyPI because the user authorized pushing code, not a package release. After the SDK feature branch is final, LangBot's feature branch temporarily pins the exact pushed SDK Git commit. Before merging to master, the release gate is to publish `langbot-plugin==0.4.18` and replace the Git pin with the registry pin. -- Reason: The current registry release does not contain the complete tenant action context and shared Runtime hardening. An exact Git commit is reproducible and keeps the feature branch testable without expanding release authority. +- Decision: The SDK tenancy protocol is released as `langbot-plugin==0.5.0` and LangBot pins that exact registry version. +- Reason: The final PyPI release contains the complete tenant action context and shared Runtime hardening, while the exact version pin keeps production installs reproducible. ### Cloud directory writes stay outside Core diff --git a/pyproject.toml b/pyproject.toml index 7ff13f8a7..d8b9c8549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@c0a53dca56d2e69d9e226e361a431c96c1d3dfd8", + "langbot-plugin==0.5.0", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 9eeee60e1..1da1903a0 100644 --- a/uv.lock +++ b/uv.lock @@ -2116,7 +2116,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c0a53dca56d2e69d9e226e361a431c96c1d3dfd8" }, + { name = "langbot-plugin", specifier = "==0.5.0" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2182,8 +2182,8 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.4.18" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c0a53dca56d2e69d9e226e361a431c96c1d3dfd8#c0a53dca56d2e69d9e226e361a431c96c1d3dfd8" } +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -2203,6 +2203,10 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/5c/09/697037dea617a235c9b3df85174badfb44886e3c67149a928049839a959a/langbot_plugin-0.5.0.tar.gz", hash = "sha256:9b81fa0f73cde1fe199a746e03ad891f8ced8f4fa0d05aeb6d78bfe7d755a976", size = 464033, upload-time = "2026-07-30T19:22:57.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/70/93e544c3a120953f4871266db9ef84ea9a58daf01d493fafa7c45674ad36/langbot_plugin-0.5.0-py3-none-any.whl", hash = "sha256:2b078db96d869de55d08304465974f51765e3cfe582ff79b32f09161292fb877", size = 300758, upload-time = "2026-07-30T19:22:56.248Z" }, +] [[package]] name = "langchain" From a5a26f81ee836be07efcb5b367301d3b6f392c90 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:05:17 +0000 Subject: [PATCH 14/33] feat(auth): accept direct Space launch assertions --- web/src/app/auth/space/callback/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 33f3a4846..9b3f9112a 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -207,8 +207,16 @@ function SpaceOAuthCallbackContent() { const errorDescription = searchParams.get('error_description'); const mode = searchParams.get('mode'); const state = searchParams.get('state'); - const workspaceUuid = searchParams.get('workspace_uuid'); - const launchAssertion = searchParams.get('launch_assertion'); + const fragmentParams = new URLSearchParams( + window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash, + ); + const workspaceUuid = + fragmentParams.get('workspace_uuid') ?? searchParams.get('workspace_uuid'); + const launchAssertion = + fragmentParams.get('launch_assertion') ?? + searchParams.get('launch_assertion'); if (error) { setStatus('error'); From 93dbd3541e5a6dd13d9d87d310b6bea93e9c15dc Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:02:38 +0000 Subject: [PATCH 15/33] fix(cloud): make direct launch replay-safe --- .../pkg/api/http/controller/groups/user.py | 1 + src/langbot/pkg/cloud/launch.py | 45 +++++++++++++-- .../pkg/entity/persistence/cloud_directory.py | 23 ++++++++ .../versions/0016_space_launch_replay.py | 57 +++++++++++++++++++ src/langbot/pkg/persistence/tenant_uow.py | 1 + tests/unit_tests/cloud/test_space_launch.py | 19 ++++++- web/src/app/auth/space/callback/page.tsx | 42 +++++++++++--- web/tests/unit/space-launch-callback.test.mjs | 24 ++++++++ 8 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py create mode 100644 web/tests/unit/space-launch-callback.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8fa38bf73..520d284e6 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -410,6 +410,7 @@ class UserRouterGroup(group.RouterGroup): 'token': token, 'user': account.user, 'workspace_uuid': access.workspace.uuid, + 'return_path': launch.get('return_path', '/home'), } ) except SpaceLaunchError: diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py index ac5006870..3725d03d8 100644 --- a/src/langbot/pkg/cloud/launch.py +++ b/src/langbot/pkg/cloud/launch.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import base64 import binascii +import datetime import hashlib import heapq import json @@ -14,6 +15,10 @@ from collections.abc import Callable, Iterable from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +import sqlalchemy +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from ..entity.persistence.cloud_directory import SpaceLaunchAssertionConsumption if typing.TYPE_CHECKING: from ..core.app import Application @@ -125,12 +130,20 @@ class SpaceLaunchService: raise SpaceLaunchError('Launch assertion payload must be a JSON object') account_uuid = _required_string(payload, 'account_uuid') workspace_uuid = _required_string(payload, 'workspace_uuid') + return_path = _required_string(payload, 'return_path') + if ( + not return_path.startswith('/') + or return_path.startswith('//') + or any(character in return_path for character in ('\\', '\r', '\n', '\t')) + ): + raise SpaceLaunchError('Launch assertion return path is invalid') if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid: raise SpaceLaunchError('Launch assertion targets another Workspace') await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) return { 'account_uuid': account_uuid, 'workspace_uuid': workspace_uuid, + 'return_path': return_path, } def _verify_assertion(self, token: str) -> dict[str, typing.Any]: @@ -214,19 +227,39 @@ class SpaceLaunchService: async def _consume_jti(self, jti: str, expires_at: int) -> None: digest = hashlib.sha256(jti.encode('utf-8')).hexdigest() now = int(self._wall_time()) + persistence_mgr = getattr(self.ap, 'persistence_mgr', None) + instance_uuid = str(self.ap.workspace_service.instance_uuid) + if persistence_mgr is not None: + expires_at_datetime = datetime.datetime.fromtimestamp(expires_at, tz=datetime.timezone.utc) + now_datetime = datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc) + async with persistence_mgr.directory_projection_uow(instance_uuid) as uow: + await uow.session.execute( + sqlalchemy.delete(SpaceLaunchAssertionConsumption).where( + SpaceLaunchAssertionConsumption.instance_uuid == instance_uuid, + SpaceLaunchAssertionConsumption.expires_at < now_datetime, + ) + ) + statement = ( + pg_insert(SpaceLaunchAssertionConsumption) + .values(instance_uuid=instance_uuid, jti=digest, expires_at=expires_at_datetime) + .on_conflict_do_nothing(index_elements=['instance_uuid', 'jti']) + .returning(SpaceLaunchAssertionConsumption.jti) + ) + result = await uow.session.execute(statement) + if result.scalar_one_or_none() is None: + raise SpaceLaunchError('Launch assertion has already been consumed') + return + + # Lightweight unit-test and OSS compatibility fallback. Verified Cloud + # runtime always supplies the durable PostgreSQL persistence manager. async with self._replay_lock: self._prune_consumed_jtis(now) if digest in self._consumed_jtis: raise SpaceLaunchError('Launch assertion has already been consumed') if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES: - # Evicting a still-valid digest would make a signed launch - # assertion replayable. Bound memory by failing closed instead. raise SpaceLaunchError('Launch assertion replay cache capacity reached') self._consumed_jtis[digest] = expires_at - heapq.heappush( - self._consumed_jti_expiry_heap, - (expires_at, digest), - ) + heapq.heappush(self._consumed_jti_expiry_heap, (expires_at, digest)) def _prune_consumed_jtis(self, now: int) -> None: while self._consumed_jti_expiry_heap: diff --git a/src/langbot/pkg/entity/persistence/cloud_directory.py b/src/langbot/pkg/entity/persistence/cloud_directory.py index 11e41cdfb..066873a14 100644 --- a/src/langbot/pkg/entity/persistence/cloud_directory.py +++ b/src/langbot/pkg/entity/persistence/cloud_directory.py @@ -67,3 +67,26 @@ class DirectoryProjectionInbox(Base): name='ck_directory_projection_inbox_fingerprint', ), ) + + +class SpaceLaunchAssertionConsumption(Base): + """Durable, instance-scoped replay ledger for signed Space launch assertions.""" + + __tablename__ = 'space_launch_assertion_consumptions' + + instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + jti = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + expires_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=False) + consumed_at = sqlalchemy.Column( + sqlalchemy.DateTime(timezone=True), + nullable=False, + server_default=sqlalchemy.func.now(), + ) + + __table_args__ = ( + sqlalchemy.Index( + 'ix_space_launch_assertion_consumptions_expiry', + 'instance_uuid', + 'expires_at', + ), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py new file mode 100644 index 000000000..33f44c19c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py @@ -0,0 +1,57 @@ +"""add durable replay protection for signed Space launch assertions + +Revision ID: 0016_space_launch_replay +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0016_space_launch_replay' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + +_TABLE = 'space_launch_assertion_consumptions' +_POLICY = 'langbot_directory_projection' +_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')" + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in set(sa.inspect(conn).get_table_names()): + op.create_table( + _TABLE, + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('instance_uuid', 'jti'), + ) + op.create_index( + 'ix_space_launch_assertion_consumptions_expiry', + _TABLE, + ['instance_uuid', 'expires_at'], + unique=False, + ) + if conn.dialect.name == 'postgresql': + table = conn.dialect.identifier_preparer.quote(_TABLE) + policy = conn.dialect.identifier_preparer.quote(_POLICY) + expression = f'instance_uuid::text = {_SETTING}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()): + op.drop_table(_TABLE) diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 27db2d64a..af472ac2d 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -75,6 +75,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { DIRECTORY_PROJECTION_TABLE_COLUMNS: dict[str, str] = { 'directory_projection_states': 'instance_uuid', 'directory_projection_inbox': 'instance_uuid', + 'space_launch_assertion_consumptions': 'instance_uuid', } DIRECTORY_PROJECTED_TENANT_TABLES = frozenset( diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py index 14b6b7f17..07d0f80de 100644 --- a/tests/unit_tests/cloud/test_space_launch.py +++ b/tests/unit_tests/cloud/test_space_launch.py @@ -48,6 +48,7 @@ def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE 'payload': { 'account_uuid': ACCOUNT_UUID, 'workspace_uuid': workspace_uuid, + 'return_path': '/', }, } @@ -81,7 +82,11 @@ async def test_consumes_valid_workspace_launch_assertion_once(): launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) - assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID} + assert launch == { + 'account_uuid': ACCOUNT_UUID, + 'workspace_uuid': WORKSPACE_UUID, + 'return_path': '/', + } with pytest.raises(SpaceLaunchError, match='already been consumed'): await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) @@ -162,3 +167,15 @@ async def test_rejects_invalid_signature_and_non_cloud_mode(): oss_service.ap.deployment.multi_workspace_enabled = False with pytest.raises(SpaceLaunchError, match='verified Cloud mode'): await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + +@pytest.mark.asyncio +async def test_rejects_unsafe_signed_return_path() -> None: + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + claims = _claims(now=now) + claims['payload']['return_path'] = '//evil.example' + + with pytest.raises(SpaceLaunchError, match='return path'): + await service.consume_assertion(_sign(private_key, claims)) diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 9b3f9112a..cd87bdc56 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -29,6 +29,7 @@ type SpaceOAuthLoginResult = { token: string; user: string; workspace_uuid?: string; + return_path?: string; }; const pendingSpaceOAuthLogins = new Map< @@ -63,6 +64,10 @@ function SpaceOAuthCallbackContent() { const [searchParams] = useSearchParams(); const { t } = useTranslation(); const isMountedRef = useRef(true); + const directLaunchFragmentRef = useRef<{ + workspaceUuid: string | null; + launchAssertion: string | null; + } | null>(null); const [status, setStatus] = useState< 'loading' | 'confirm' | 'success' | 'error' @@ -106,7 +111,13 @@ function SpaceOAuthCallbackContent() { throw new Error('No Workspace is available for this Account'); } if (response.workspace_uuid) { - navigate('/home', { replace: true }); + const returnPath = + typeof response.return_path === 'string' && + response.return_path.startsWith('/') && + !response.return_path.startsWith('//') + ? response.return_path + : '/home'; + navigate(returnPath, { replace: true }); return; } setStatus('success'); @@ -207,16 +218,29 @@ function SpaceOAuthCallbackContent() { const errorDescription = searchParams.get('error_description'); const mode = searchParams.get('mode'); const state = searchParams.get('state'); - const fragmentParams = new URLSearchParams( - window.location.hash.startsWith('#') - ? window.location.hash.slice(1) - : window.location.hash, - ); + if (directLaunchFragmentRef.current === null) { + const fragmentParams = new URLSearchParams( + window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash, + ); + directLaunchFragmentRef.current = { + workspaceUuid: fragmentParams.get('workspace_uuid'), + launchAssertion: fragmentParams.get('launch_assertion'), + }; + if (window.location.hash) { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}`, + ); + } + } const workspaceUuid = - fragmentParams.get('workspace_uuid') ?? searchParams.get('workspace_uuid'); + directLaunchFragmentRef.current.workspaceUuid ?? + searchParams.get('workspace_uuid'); const launchAssertion = - fragmentParams.get('launch_assertion') ?? - searchParams.get('launch_assertion'); + directLaunchFragmentRef.current.launchAssertion; if (error) { setStatus('error'); diff --git a/web/tests/unit/space-launch-callback.test.mjs b/web/tests/unit/space-launch-callback.test.mjs new file mode 100644 index 000000000..694cbcc6d --- /dev/null +++ b/web/tests/unit/space-launch-callback.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url), + 'utf8', +); + +test('direct launch assertion is fragment-only and removed before exchange', () => { + assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/); + const readIndex = source.indexOf("fragmentParams.get('launch_assertion')"); + const clearIndex = source.indexOf('window.history.replaceState'); + const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex); + assert.ok(readIndex >= 0, 'fragment assertion read is missing'); + assert.ok(clearIndex > readIndex, 'URL fragment is not cleared after copying the assertion'); + assert.ok(exchangeIndex > clearIndex, 'assertion exchange starts before the fragment is cleared'); +}); + +test('direct launch honors only a local signed return path', () => { + assert.match(source, /response\.return_path\.startsWith\(['"]\/['"]\)/); + assert.match(source, /!response\.return_path\.startsWith\(['"]\/\/['"]\)/); + assert.match(source, /navigate\(returnPath, \{ replace: true \}\)/); +}); From 473ba573a39aef82135a9eafcd0dbe89ba0e3ae9 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:37:06 +0000 Subject: [PATCH 16/33] fix(cloud): retain launch replay records through clock skew --- src/langbot/pkg/cloud/launch.py | 10 ++++++---- tests/unit_tests/cloud/test_space_launch.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py index 3725d03d8..26fcf32ae 100644 --- a/src/langbot/pkg/cloud/launch.py +++ b/src/langbot/pkg/cloud/launch.py @@ -7,6 +7,7 @@ import datetime import hashlib import heapq import json +import math import os import time import typing @@ -124,7 +125,7 @@ class SpaceLaunchService: *, expected_workspace_uuid: str | None = None, ) -> dict[str, str]: - claims = self._verify_assertion(assertion) + claims, clock_skew_seconds = self._verify_assertion(assertion) payload = claims.get('payload') if not isinstance(payload, dict): raise SpaceLaunchError('Launch assertion payload must be a JSON object') @@ -139,14 +140,15 @@ class SpaceLaunchService: raise SpaceLaunchError('Launch assertion return path is invalid') if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid: raise SpaceLaunchError('Launch assertion targets another Workspace') - await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) + replay_retention_expires_at = _required_int(claims, 'exp', minimum=1) + math.ceil(clock_skew_seconds) + await self._consume_jti(_required_string(claims, 'jti'), replay_retention_expires_at) return { 'account_uuid': account_uuid, 'workspace_uuid': workspace_uuid, 'return_path': return_path, } - def _verify_assertion(self, token: str) -> dict[str, typing.Any]: + def _verify_assertion(self, token: str) -> tuple[dict[str, typing.Any], float]: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): raise SpaceLaunchError('Space direct launch requires verified Cloud mode') public_key, key_id, clock_skew_seconds = self._trust_config() @@ -197,7 +199,7 @@ class SpaceLaunchService: raise SpaceLaunchError('Launch assertion is expired') if expires_at <= max(issued_at, not_before): raise SpaceLaunchError('Launch assertion expiry must follow issue time') - return claims + return claims, clock_skew_seconds def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]: data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py index 07d0f80de..51aa0edb8 100644 --- a/tests/unit_tests/cloud/test_space_launch.py +++ b/tests/unit_tests/cloud/test_space_launch.py @@ -91,6 +91,22 @@ async def test_consumes_valid_workspace_launch_assertion_once(): await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) +async def test_consumed_assertion_remains_blocked_through_clock_skew_window(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + claims = _claims(now=now) + claims['iat'] = now - 10 + claims['nbf'] = now - 10 + claims['exp'] = now - 1 + token = _sign(private_key, claims) + + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch): private_key = Ed25519PrivateKey.generate() now = int(time.time()) From 0330788d149924c9c2731b518ec9ce52aa8e9445 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:46 +0000 Subject: [PATCH 17/33] chore(prod): release workspace monitoring --- .github/workflows/deploy-prod.yml | 78 ++++++++++++++ deploy/prod/deploy.sh | 97 ++++++++++++++++++ deploy/prod/docker-compose.yml | 162 ++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 .github/workflows/deploy-prod.yml create mode 100755 deploy/prod/deploy.sh create mode 100644 deploy/prod/docker-compose.yml diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 000000000..b3fa54543 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,78 @@ +name: Build and deploy production + +on: + push: + branches: [deploy/prod] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: langbot-production + cancel-in-progress: false + +env: + CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot + CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core + SPACE_REF: ec9ece010bb5f3ffd6e400b9992ca436454b12e2 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build exact Core image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + ${{ env.CORE_IMAGE }}:deploy-prod + cache-from: type=gha,scope=core-prod + cache-to: type=gha,mode=max,scope=core-prod + - name: Checkout production Cloud adapter + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-space + ref: ${{ env.SPACE_REF }} + token: ${{ secrets.CLA_PAT }} + path: .space + - name: Build exact Cloud Core image + uses: docker/build-push-action@v6 + with: + context: .space + file: .space/Dockerfile.cloud + push: true + build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }} + tags: | + ${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }} + ${{ env.CLOUD_IMAGE }}:deploy-prod + cache-from: type=gha,scope=cloud-core-prod + cache-to: type=gha,mode=max,scope=cloud-core-prod + - name: Configure SSH + env: + SSH_KEY: ${{ secrets.JP09_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }} + run: | + install -m 700 -d ~/.ssh + install -m 600 /dev/null ~/.ssh/id_ed25519 + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + - name: Upload release manifest and deploy + env: + HOST: ${{ secrets.JP09_HOST }} + USER: ${{ secrets.JP09_USER }} + PORT: ${{ secrets.JP09_PORT }} + run: | + remote="$USER@$HOST" + ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod' + scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/" + ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}" diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh new file mode 100755 index 000000000..dc51f89a5 --- /dev/null +++ b/deploy/prod/deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd /opt/langbot-cloud-prod +TAG=${1:?usage: deploy.sh prod-<40-char-sha>} +[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; } +[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; } + +rendered_compose=$(docker compose config) +grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud control-plane URL must be https://space.langbot.app' >&2 + exit 4 +} +grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || { + echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2 + exit 5 +} +grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || { + echo 'Cloud telemetry ingest token must be configured' >&2 + exit 6 +} + +update_env() { + local key=$1 value=$2 + python3 - "$key" "$value" <<'PY' +from pathlib import Path +import os +import sys + +path = Path('.env') +key, value = sys.argv[1:] +lines = path.read_text().splitlines() +updated = False +for index, line in enumerate(lines): + if line.startswith(f'{key}='): + lines[index] = f'{key}={value}' + updated = True + break +if not updated: + lines.append(f'{key}={value}') +temporary = Path('.env.tmp') +temporary.write_text('\n'.join(lines) + '\n') +os.chmod(temporary, 0o600) +temporary.replace(path) +PY +} +update_env LANGBOT_IMAGE_TAG "$TAG" +set -a +. ./.env +set +a +: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}" + +for attempt in 1 2 3 4 5; do + if docker compose pull postgres redis migrate plugin-runtime core; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "docker compose pull failed after $attempt attempts" >&2 + exit 1 + fi + delay=$((attempt * 10)) + echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2 + sleep "$delay" +done +docker compose up -d postgres redis +for _ in $(seq 1 60); do + if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi + sleep 2 +done +docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null + +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \ + -v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL' +SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec +ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; +GRANT CONNECT ON DATABASE langbot TO langbot_runtime; +REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime; +REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime; +GRANT USAGE ON SCHEMA public TO langbot_runtime; +SQL + +docker compose --profile tools run --rm migrate + +docker compose up -d --remove-orphans plugin-runtime core +for _ in $(seq 1 90); do + if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then + docker compose ps + exit 0 + fi + sleep 2 +done +docker compose logs --tail=200 core plugin-runtime >&2 +exit 1 diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 000000000..db224b4f0 --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,162 @@ +services: + postgres: + image: pgvector/pgvector:pg17 + container_name: langbot-cloud-postgres + restart: unless-stopped + environment: + POSTGRES_DB: langbot + POSTGRES_USER: langbot_operator + POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"] + interval: 5s + timeout: 5s + retries: 30 + networks: [internal] + + redis: + image: redis:7.4-alpine + container_name: langbot-cloud-redis + restart: unless-stopped + command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"] + volumes: + - redis-data:/data + healthcheck: + test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] + interval: 5s + timeout: 5s + retries: 20 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + networks: [internal] + + migrate: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + profiles: [tools] + command: [uv, run, langbot, migrate, --cloud] + environment: &core-env + TZ: Asia/Shanghai + SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID} + SYSTEM__EDITION: cloud + SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY} + SYSTEM__JWT__SECRET: ${JWT_SECRET} + SYSTEM__LIMITATION__MAX_BOTS: "2" + SYSTEM__LIMITATION__MAX_PIPELINES: "3" + SYSTEM__LIMITATION__MAX_EXTENSIONS: "3" + SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2" + API__WEBHOOK_PREFIX: https://cloud.langbot.app + API__WEBUI_URL: https://cloud.langbot.app + WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app + DATABASE__USE: postgresql + DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot + DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN + LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot + VDB__USE: pgvector + VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" + VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" + PLUGIN__ENABLE: "true" + PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws + PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws + PLUGIN__WORKER__MAX_CPUS: "0.25" + PLUGIN__WORKER__MAX_MEMORY_MB: "256" + PLUGIN__WORKER__MAX_PIDS: "128" + PLUGIN__WORKER__MAX_WORKERS: "16" + PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0" + PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096" + PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true" + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + # Cloud v2 currently grants no managed Box capability. Keep the shared + # runtime deployed but disable Core integration until a hard-quota-capable + # backend can satisfy the fail-closed Cloud readiness contract. + BOX__ENABLED: "false" + BOX__BACKEND: nsjail + BOX__RUNTIME__ENDPOINT: ws://box:5410 + BOX__ADMISSION__REQUIRED: "true" + BOX__ADMISSION__LOGICAL_SESSION_ID: global + BOX__ADMISSION__REQUIRED_BACKEND: nsjail + BOX__ADMISSION__MAX_SESSIONS: "1" + BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0" + BOX__ADMISSION__CPUS: "0.25" + BOX__ADMISSION__MEMORY_MB: "256" + BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256" + BOX__LOCAL__HOST_ROOT: /app/data/box + BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box + BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + MCP__STDIO__ENABLED: "false" + LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app + LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} + LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} + LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY} + LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID} + SPACE__URL: https://space.langbot.app + depends_on: + postgres: {condition: service_healthy} + networks: [internal] + + plugin-runtime: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-plugin-runtime + restart: unless-stopped + command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt] + environment: + LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} + volumes: + - plugin-data:/app/data + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5400"] + networks: [internal] + + box: + image: rockchin/langbot:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-box + restart: unless-stopped + command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"] + environment: + LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} + LANGBOT_BOX_ROOT: /app/data/box + volumes: + - box-data:/app/data/box + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroup: host + privileged: true + expose: ["5410"] + networks: [internal] + + core: + image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} + container_name: langbot-cloud-core + restart: unless-stopped + environment: *core-env + volumes: + - core-data:/app/data + - box-data:/app/data/box + depends_on: + postgres: {condition: service_healthy} + redis: {condition: service_healthy} + plugin-runtime: {condition: service_started} + box: {condition: service_started} + expose: ["5300"] + healthcheck: + test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + networks: [internal, shared-network] + +networks: + internal: + shared-network: + external: true + +volumes: + postgres-data: + redis-data: + plugin-data: + box-data: + core-data: From 2456bf13504f31bbf765087a29d560ec8c2e48ca Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:35:21 +0000 Subject: [PATCH 18/33] fix(migrations): preserve published Cloud revision head --- .../versions/0016_space_launch_replay.py | 57 +++++++++++++++++++ .../versions/0018_merge_launch_replay.py | 21 +++++++ .../persistence/test_migrations.py | 12 ++++ 3 files changed, 90 insertions(+) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py new file mode 100644 index 000000000..33f44c19c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py @@ -0,0 +1,57 @@ +"""add durable replay protection for signed Space launch assertions + +Revision ID: 0016_space_launch_replay +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0016_space_launch_replay' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + +_TABLE = 'space_launch_assertion_consumptions' +_POLICY = 'langbot_directory_projection' +_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')" + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in set(sa.inspect(conn).get_table_names()): + op.create_table( + _TABLE, + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('instance_uuid', 'jti'), + ) + op.create_index( + 'ix_space_launch_assertion_consumptions_expiry', + _TABLE, + ['instance_uuid', 'expires_at'], + unique=False, + ) + if conn.dialect.name == 'postgresql': + table = conn.dialect.identifier_preparer.quote(_TABLE) + policy = conn.dialect.identifier_preparer.quote(_POLICY) + expression = f'instance_uuid::text = {_SETTING}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()): + op.drop_table(_TABLE) diff --git a/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py new file mode 100644 index 000000000..68f4e1007 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py @@ -0,0 +1,21 @@ +"""merge the published Space launch replay and main migration branches + +Revision ID: 0018_merge_launch_replay +Revises: 0016_space_launch_replay, 0017_oss_workspace_identity +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +revision = '0018_merge_launch_replay' +down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index c1a455fb1..3447c9320 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -95,6 +95,18 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" + @pytest.mark.asyncio + async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): + """A database released at the production-only 0016 head must remain upgradable.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == _get_script_head() + assert _get_script_head() == '0018_merge_launch_replay' + @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): """ From c7d14676fc6e1d37f76e3ac8f82082c645b2e8f3 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:11:17 +0000 Subject: [PATCH 19/33] fix(cloud): restore disabled Box production mode --- .github/workflows/deploy-prod.yml | 19 ------------------- src/langbot/pkg/cloud/bootstrap.py | 10 ++++++++-- tests/unit_tests/cloud/test_bootstrap.py | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index b3fa54543..943679504 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -57,22 +57,3 @@ jobs: ${{ env.CLOUD_IMAGE }}:deploy-prod cache-from: type=gha,scope=cloud-core-prod cache-to: type=gha,mode=max,scope=cloud-core-prod - - name: Configure SSH - env: - SSH_KEY: ${{ secrets.JP09_SSH_KEY }} - KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }} - run: | - install -m 700 -d ~/.ssh - install -m 600 /dev/null ~/.ssh/id_ed25519 - printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 - printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts - - name: Upload release manifest and deploy - env: - HOST: ${{ secrets.JP09_HOST }} - USER: ${{ secrets.JP09_USER }} - PORT: ${{ secrets.JP09_PORT }} - run: | - remote="$USER@$HOST" - ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod' - scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/" - ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}" diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py index 8e9ee1886..c8341e56c 100644 --- a/src/langbot/pkg/cloud/bootstrap.py +++ b/src/langbot/pkg/cloud/bootstrap.py @@ -138,8 +138,14 @@ class VerifiedCloudDeployment: if plugin_worker.get('require_hard_limits') is not True: raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true') box_config = config.get('box', {}) - if box_config.get('enabled') is not True: - raise CloudBootstrapError('Cloud runtime requires box.enabled=true') + box_enabled = box_config.get('enabled') + if box_enabled is False: + # Explicitly disabling Box removes the sandbox surface entirely and + # therefore does not weaken tenant isolation. Validate the strict + # runtime/admission contract only when the surface is enabled. + return + if box_enabled is not True: + raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean') if box_config.get('backend') != 'nsjail': raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail') runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip() diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py index 77ccefa5d..e27367d8d 100644 --- a/tests/unit_tests/cloud/test_bootstrap.py +++ b/tests/unit_tests/cloud/test_bootstrap.py @@ -228,10 +228,23 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message): ) +async def test_cloud_runtime_allows_explicitly_disabled_box(): + config = _cloud_config() + config['box']['enabled'] = False + + deployment = await resolve_deployment( + instance_uuid='instance-a', + instance_config=config, + entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]), + now=1_000, + ) + + assert isinstance(deployment, VerifiedCloudDeployment) + + @pytest.mark.parametrize( ('mutate', 'message'), [ - (lambda config: config['box'].update(enabled=False), 'box.enabled=true'), (lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'), (lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'), ( From 161ea9b3ebd4423cb450ed129060c8081bd6e84d Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:41:44 +0000 Subject: [PATCH 20/33] fix(cloud): restore fragment-based Space launch callback --- web/src/app/auth/space/callback/page.tsx | 29 +++++++++++++++++-- web/tests/unit/space-launch-callback.test.mjs | 18 ++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 web/tests/unit/space-launch-callback.test.mjs diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 1028de6ac..cfce808fd 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -66,6 +66,10 @@ function SpaceOAuthCallbackContent() { const [searchParams] = useSearchParams(); const { t } = useTranslation(); const isMountedRef = useRef(true); + const directLaunchFragmentRef = useRef<{ + workspaceUuid: string | null; + launchAssertion: string | null; + } | null>(null); const [status, setStatus] = useState< 'loading' | 'confirm' | 'success' | 'error' @@ -220,8 +224,29 @@ function SpaceOAuthCallbackContent() { const errorDescription = searchParams.get('error_description'); const mode = searchParams.get('mode'); const state = searchParams.get('state'); - const workspaceUuid = searchParams.get('workspace_uuid'); - const launchAssertion = searchParams.get('launch_assertion'); + if (directLaunchFragmentRef.current === null) { + const fragmentParams = new URLSearchParams( + window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash, + ); + directLaunchFragmentRef.current = { + workspaceUuid: fragmentParams.get('workspace_uuid'), + launchAssertion: fragmentParams.get('launch_assertion'), + }; + if (window.location.hash) { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}`, + ); + } + } + const workspaceUuid = + directLaunchFragmentRef.current.workspaceUuid ?? + searchParams.get('workspace_uuid'); + const launchAssertion = + directLaunchFragmentRef.current.launchAssertion; if (error) { setStatus('error'); diff --git a/web/tests/unit/space-launch-callback.test.mjs b/web/tests/unit/space-launch-callback.test.mjs new file mode 100644 index 000000000..ff4ac419c --- /dev/null +++ b/web/tests/unit/space-launch-callback.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url), + 'utf8', +); + +test('direct launch assertion is fragment-only and removed before exchange', () => { + assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/); + const readIndex = source.indexOf("fragmentParams.get('launch_assertion')"); + const clearIndex = source.indexOf('window.history.replaceState'); + const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex); + assert.ok(readIndex >= 0, 'fragment assertion read is missing'); + assert.ok(clearIndex > readIndex, 'URL fragment is not cleared after copying the assertion'); + assert.ok(exchangeIndex > clearIndex, 'assertion exchange starts before the fragment is cleared'); +}); From d64278ab3fef1593cceb84135fe1d6681d3df87b Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 1 Aug 2026 18:16:02 +0800 Subject: [PATCH 21/33] feat(cloud): provision workspace model catalog (#2376) * feat(cloud): provision workspace model catalog * ci(cloud): pin model catalog adapter source * fix: make cloud model catalog sync recoverable * ci: pin cloud adapter source for release --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .github/workflows/deploy-prod.yml | 2 +- pyproject.toml | 2 +- src/langbot/pkg/api/http/service/model.py | 75 +++- src/langbot/pkg/api/http/service/provider.py | 23 ++ src/langbot/pkg/cloud/bootstrap.py | 5 + src/langbot/pkg/cloud/model_catalog.py | 315 +++++++++++++++ src/langbot/pkg/core/app.py | 12 +- src/langbot/pkg/core/stages/build_app.py | 11 + .../test_cloud_managed_model_protection.py | 112 ++++++ .../api/service/test_provider_service.py | 54 +++ tests/unit_tests/cloud/test_bootstrap.py | 5 + tests/unit_tests/cloud/test_model_catalog.py | 363 ++++++++++++++++++ uv.lock | 2 +- web/src/app/auth/space/callback/page.tsx | 3 +- web/tests/unit/space-launch-callback.test.mjs | 10 +- 15 files changed, 979 insertions(+), 15 deletions(-) create mode 100644 src/langbot/pkg/cloud/model_catalog.py create mode 100644 tests/unit_tests/api/service/test_cloud_managed_model_protection.py create mode 100644 tests/unit_tests/cloud/test_model_catalog.py diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 943679504..acf33d0e5 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -15,7 +15,7 @@ concurrency: env: CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core - SPACE_REF: ec9ece010bb5f3ffd6e400b9992ca436454b12e2 + SPACE_REF: 178b1634c104e635c706e1fb4ca37cb3de073cf9 jobs: build-and-deploy: diff --git a/pyproject.toml b/pyproject.toml index d1e588fa8..28890f610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.6" +version = "4.10.7" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 88801c6b8..898a791e2 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -5,6 +5,7 @@ import uuid import sqlalchemy from langbot_plugin.api.entities.builtin.provider import message as provider_message +from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline @@ -113,6 +114,23 @@ async def _require_workspace_provider( return provider +def _is_cloud_runtime(ap: app.Application) -> bool: + mode = getattr(ap.persistence_mgr, 'mode', None) + return getattr(mode, 'value', None) == 'cloud_runtime' + + +async def _assert_cloud_managed_provider_mutable( + ap: app.Application, + context: TenantContext, + provider_uuid: str, +) -> None: + if not _is_cloud_runtime(ap): + return + provider = await _require_workspace_provider(ap, context, provider_uuid) + if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER: + raise ValueError('LangBot Models is managed by Cloud and cannot be modified') + + async def _require_runtime_provider( ap: app.Application, context: TenantContext, @@ -213,6 +231,7 @@ class LLMModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm') await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) @@ -291,11 +310,17 @@ class LLMModelsService: return model_dict - async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_llm_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing LLM model""" existing_model = await self.get_llm_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -321,6 +346,7 @@ class LLMModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'llm') result = await self.ap.persistence_mgr.execute_async( @@ -355,6 +381,11 @@ class LLMModelsService: async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None: """Delete an LLM model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_llm_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid), @@ -448,7 +479,10 @@ class EmbeddingModelsService: return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] async def create_embedding_model( - self, context: TenantContext, model_data: dict, preserve_uuid: bool = False + self, + context: TenantContext, + model_data: dict, + preserve_uuid: bool = False, ) -> str: """Create a new embedding model""" model_data = model_data.copy() @@ -472,6 +506,7 @@ class EmbeddingModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding') await self.ap.persistence_mgr.execute_async( @@ -530,11 +565,17 @@ class EmbeddingModelsService: return model_dict - async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_embedding_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing embedding model""" existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -559,6 +600,7 @@ class EmbeddingModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding') result = await self.ap.persistence_mgr.execute_async( @@ -593,6 +635,11 @@ class EmbeddingModelsService: async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None: """Delete an embedding model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.EmbeddingModel).where( @@ -685,7 +732,12 @@ class RerankModelsService: serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models] return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] - async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str: + async def create_rerank_model( + self, + context: TenantContext, + model_data: dict, + preserve_uuid: bool = False, + ) -> str: """Create a new rerank model""" model_data = model_data.copy() if not preserve_uuid: @@ -708,6 +760,7 @@ class RerankModelsService: model_data['provider_uuid'] = provider_uuid await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) + await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank') await self.ap.persistence_mgr.execute_async( @@ -766,11 +819,17 @@ class RerankModelsService: return model_dict - async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None: + async def update_rerank_model( + self, + context: TenantContext, + model_uuid: str, + model_data: dict, + ) -> None: """Update an existing rerank model""" existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True) if existing_model is None: raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) model_data = model_data.copy() model_data.pop('uuid', None) model_data.pop('workspace_uuid', None) @@ -795,6 +854,7 @@ class RerankModelsService: provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) await _require_workspace_provider(self.ap, context, provider_uuid) + await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank') result = await self.ap.persistence_mgr.execute_async( @@ -829,6 +889,11 @@ class RerankModelsService: async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None: """Delete a rerank model""" + if _is_cloud_runtime(self.ap): + existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True) + if existing_model is None: + raise WorkspaceNotFoundError('Model not found') + await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid']) result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.delete(persistence_model.RerankModel).where( diff --git a/src/langbot/pkg/api/http/service/provider.py b/src/langbot/pkg/api/http/service/provider.py index 74647d7ab..dc29d3858 100644 --- a/src/langbot/pkg/api/http/service/provider.py +++ b/src/langbot/pkg/api/http/service/provider.py @@ -5,6 +5,7 @@ import traceback import sqlalchemy +from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER from ....core import app from ....entity.persistence import model as persistence_model from ....workspace.errors import WorkspaceNotFoundError @@ -20,6 +21,20 @@ class ModelProviderService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _is_cloud_runtime(self) -> bool: + mode = getattr(self.ap.persistence_mgr, 'mode', None) + return getattr(mode, 'value', None) == 'cloud_runtime' + + def _system_requester_is_reserved(self, requester: object) -> bool: + return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER + + async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None: + if not self._is_cloud_runtime(): + return + provider = await self.get_provider(context, provider_uuid) + if provider is not None and self._system_requester_is_reserved(provider.get('requester')): + raise ValueError('LangBot Models is managed by Cloud and cannot be modified') + @staticmethod def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]: if api_keys is None: @@ -99,6 +114,8 @@ class ModelProviderService: async def create_provider(self, context: TenantContext, provider_data: dict) -> str: """Create a new provider""" provider_data = provider_data.copy() + if self._system_requester_is_reserved(provider_data.get('requester')): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') provider_data['uuid'] = str(uuid.uuid4()) provider_data['workspace_uuid'] = require_workspace_uuid(context) provider_data['api_keys'] = self._normalize_api_keys( @@ -115,7 +132,10 @@ class ModelProviderService: async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None: """Update an existing provider""" + await self._assert_provider_mutable(context, provider_uuid) provider_data = provider_data.copy() + if self._system_requester_is_reserved(provider_data.get('requester')): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') provider_data.pop('uuid', None) provider_data.pop('workspace_uuid', None) if 'api_keys' in provider_data: @@ -145,6 +165,7 @@ class ModelProviderService: async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None: """Delete a provider (only if no models reference it)""" + await self._assert_provider_mutable(context, provider_uuid) workspace_uuid = require_workspace_uuid(context) # Check if any models use this provider llm_result = await self.ap.persistence_mgr.execute_async( @@ -245,6 +266,8 @@ class ModelProviderService: api_keys: list, ) -> str: """Find existing provider or create new one""" + if self._system_requester_is_reserved(requester): + raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') workspace_uuid = require_workspace_uuid(context) api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True)) diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py index c8341e56c..2c7ddafd2 100644 --- a/src/langbot/pkg/cloud/bootstrap.py +++ b/src/langbot/pkg/cloud/bootstrap.py @@ -13,6 +13,7 @@ from typing import Any, Protocol, runtime_checkable from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider +from .model_catalog import CloudModelCatalogProvider CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap' @@ -50,6 +51,7 @@ class OpenSourceDeployment: ) directory_provider: None = None manifest_provider: None = None + model_catalog_provider: None = None persistence_mode: str = 'oss_compat' required_vector_backend: str | None = None @@ -80,6 +82,7 @@ class VerifiedCloudDeployment: entitlement_provider: EntitlementProvider directory_provider: DirectoryProjectionProvider manifest_provider: CloudManifestProvider + model_catalog_provider: CloudModelCatalogProvider verification_key_id: str mode: str = dataclasses.field(default='cloud', init=False) workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False) @@ -110,6 +113,8 @@ class VerifiedCloudDeployment: raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter') if not isinstance(self.manifest_provider, CloudManifestProvider): raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter') + if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider): + raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter') def validate_instance_config(self, config: dict[str, Any]) -> None: try: diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py new file mode 100644 index 000000000..48b80b24b --- /dev/null +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime +from typing import Any, Literal, Protocol, runtime_checkable + +import sqlalchemy +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator + +from ..entity.persistence import model as persistence_model + + +LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions' +LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models' +_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921') +_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'} +_MODEL_TABLES = ( + persistence_model.LLMModel, + persistence_model.EmbeddingModel, + persistence_model.RerankModel, +) + + +class CloudModelCatalogItem(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + uuid: str = Field(min_length=1, max_length=255) + model_id: str = Field(min_length=1, max_length=255) + category: Literal['chat', 'embedding', 'rerank'] + llm_abilities: tuple[str, ...] = () + is_featured: bool = False + featured_order: int = 0 + + @field_validator('llm_abilities') + @classmethod + def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(not item.strip() or len(item) > 64 for item in value): + raise ValueError('Model abilities must be non-empty strings of at most 64 characters') + if len(set(value)) != len(value): + raise ValueError('Model abilities must be unique') + return value + + +class CloudWorkspaceModelBilling(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + workspace_uuid: str = Field(min_length=36, max_length=36) + owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36) + api_key: SecretStr | None = None + + @field_validator('workspace_uuid') + @classmethod + def validate_uuid(cls, value: str) -> str: + return str(uuid.UUID(value)) + + @field_validator('owner_account_uuid') + @classmethod + def validate_optional_uuid(cls, value: str | None) -> str | None: + return None if value is None else str(uuid.UUID(value)) + + +class CloudModelCatalogSnapshot(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + instance_uuid: str = Field(min_length=1, max_length=255) + generated_at: datetime + base_url: str = Field(min_length=1, max_length=512) + models: tuple[CloudModelCatalogItem, ...] + workspaces: tuple[CloudWorkspaceModelBilling, ...] + + @field_validator('base_url') + @classmethod + def validate_base_url(cls, value: str) -> str: + normalized = value.rstrip('/') + if not normalized.startswith('https://'): + raise ValueError('Cloud model gateway base URL must use HTTPS') + return normalized + + @field_validator('models') + @classmethod + def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]: + if len(value) > 500: + raise ValueError('Cloud model catalog exceeds 500 models') + identities = {(item.category, item.uuid) for item in value} + if len(identities) != len(value): + raise ValueError('Cloud model catalog contains duplicate model identities') + return value + + @field_validator('workspaces') + @classmethod + def validate_workspaces( + cls, value: tuple[CloudWorkspaceModelBilling, ...] + ) -> tuple[CloudWorkspaceModelBilling, ...]: + if len(value) > 10_000: + raise ValueError('Cloud model catalog exceeds 10000 Workspaces') + identities = {item.workspace_uuid for item in value} + if len(identities) != len(value): + raise ValueError('Cloud model catalog contains duplicate Workspaces') + return value + + +@runtime_checkable +class CloudModelCatalogProvider(Protocol): + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + """Fetch and verify the complete model catalog and Workspace billing projection.""" + ... + + +def system_provider_uuid(workspace_uuid: str) -> str: + workspace = str(uuid.UUID(workspace_uuid)) + return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}')) + + +def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str: + workspace = str(uuid.UUID(workspace_uuid)) + if category not in _SUPPORTED_CATEGORIES: + raise ValueError(f'Unsupported model category: {category}') + if not upstream_uuid: + raise ValueError('Upstream model UUID is required') + return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}')) + + +class CloudModelCatalogSyncService: + """Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace.""" + + def __init__( + self, + ap: Any, + provider: CloudModelCatalogProvider, + instance_uuid: str, + *, + sync_interval_seconds: float = 3600.0, + ) -> None: + if not isinstance(provider, CloudModelCatalogProvider): + raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider') + if sync_interval_seconds < 10: + raise ValueError('Cloud model catalog sync interval must be at least 10 seconds') + self.ap = ap + self.provider = provider + self.instance_uuid = instance_uuid + self.sync_interval_seconds = float(sync_interval_seconds) + # A tenant UoW commits one Workspace at a time. Keep a durable in-memory + # convergence marker so a failed runtime reload is retried even when the + # following database reconciliation is a no-op. + self._runtime_reload_pending = False + + async def initialize(self) -> None: + await self.sync_once(reload_runtime=False) + + async def run(self) -> None: + while True: + await asyncio.sleep(self.sync_interval_seconds) + try: + await self.sync_once(reload_runtime=True) + except asyncio.CancelledError: + raise + except Exception as exc: + # Exception messages can contain rendered SQL bound values, + # including provider API keys. Log only the exception class. + self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})') + + async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]: + summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0} + snapshot: CloudModelCatalogSnapshot | None = None + sync_error: Exception | None = None + reload_error: Exception | None = None + try: + snapshot = await self.provider.fetch_model_catalog(self.instance_uuid) + if snapshot.instance_uuid != self.instance_uuid: + raise ValueError('Cloud model catalog targets another LangBot instance') + + bindings = await self.ap.workspace_service.list_active_execution_bindings() + billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces} + missing = sorted( + binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace + ) + if missing: + raise ValueError( + f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces' + ) + + for binding in bindings: + counts = await self._sync_workspace( + binding.workspace_uuid, + snapshot, + billing_by_workspace[binding.workspace_uuid], + ) + summary['workspaces'] += 1 + workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted')) + if workspace_changed: + # _sync_workspace returns only after its tenant UoW commits. + self._runtime_reload_pending = True + for key in ('created', 'updated', 'deleted'): + summary[key] += counts[key] + except Exception as exc: + sync_error = exc + finally: + model_mgr = getattr(self.ap, 'model_mgr', None) + if reload_runtime and self._runtime_reload_pending and model_mgr is not None: + try: + await model_mgr.load_models_from_db() + except Exception as exc: + reload_error = exc + else: + self._runtime_reload_pending = False + + if sync_error is not None: + if reload_error is not None: + raise sync_error from reload_error + raise sync_error + if reload_error is not None: + raise reload_error + + changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted')) + if changed and snapshot is not None: + self.ap.logger.info( + 'Cloud model catalog synchronized ' + f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, ' + f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})' + ) + return summary + + async def _sync_workspace( + self, + workspace_uuid: str, + snapshot: CloudModelCatalogSnapshot, + billing: CloudWorkspaceModelBilling, + ) -> dict[str, int]: + counts = {'created': 0, 'updated': 0, 'deleted': 0} + provider_uuid = system_provider_uuid(workspace_uuid) + desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else [] + + async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + provider = await uow.session.scalar( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == provider_uuid + ) + ) + provider_values = { + 'workspace_uuid': workspace_uuid, + 'name': LANGBOT_MODELS_PROVIDER_NAME, + 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER, + 'base_url': snapshot.base_url, + 'api_keys': desired_keys, + } + if provider is None: + provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values) + uow.session.add(provider) + await uow.session.flush() + counts['created'] += 1 + elif self._update_entity(provider, provider_values): + counts['updated'] += 1 + + existing_by_table: dict[type, dict[str, Any]] = {} + for table in _MODEL_TABLES: + rows = ( + await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid)) + ).all() + existing_by_table[table] = {row.uuid: row for row in rows} + + desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES} + for item in snapshot.models: + table, values = self._model_values(workspace_uuid, provider_uuid, item) + model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid) + desired_ids[table].add(model_uuid) + existing = existing_by_table[table].get(model_uuid) + if existing is None: + uow.session.add(table(uuid=model_uuid, **values)) + counts['created'] += 1 + elif self._update_entity(existing, values): + counts['updated'] += 1 + + for table, entities in existing_by_table.items(): + for model_uuid, entity in entities.items(): + if model_uuid not in desired_ids[table]: + await uow.session.delete(entity) + counts['deleted'] += 1 + + return counts + + @staticmethod + def _update_entity(entity: Any, values: dict[str, Any]) -> bool: + changed = False + for key, value in values.items(): + if getattr(entity, key) != value: + setattr(entity, key, value) + changed = True + return changed + + @staticmethod + def _model_values( + workspace_uuid: str, + provider_uuid: str, + item: CloudModelCatalogItem, + ) -> tuple[type, dict[str, Any]]: + ranking = 100 - item.featured_order if item.is_featured else 0 + common = { + 'workspace_uuid': workspace_uuid, + 'name': item.model_id, + 'provider_uuid': provider_uuid, + 'extra_args': {}, + 'prefered_ranking': ranking, + } + if item.category == 'chat': + return persistence_model.LLMModel, { + **common, + 'abilities': list(item.llm_abilities), + 'context_length': None, + } + if item.category == 'embedding': + return persistence_model.EmbeddingModel, common + if item.category == 'rerank': + return persistence_model.RerankModel, common + raise ValueError(f'Unsupported model category: {item.category}') diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index bfd01387d..3f4a17329 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -54,6 +54,7 @@ from ..cloud import launch as cloud_launch_module from ..cloud import support_admin as cloud_support_admin_module from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import entitlements as cloud_entitlements_module +from ..cloud import model_catalog as cloud_model_catalog_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType @@ -142,13 +143,12 @@ class Application: deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None - + directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None + cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None - directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None - vector_db_mgr: vectordb_mgr.VectorDBManager = None http_ctrl: http_controller.HTTPController = None @@ -306,6 +306,12 @@ class Application: name='cloud-directory-projection', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + if self.cloud_model_catalog_service is not None: + self.task_mgr.create_task( + self.cloud_model_catalog_service.run(), + name='cloud-model-catalog-sync', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) if self.manifest_refresh_service is not None: self.task_mgr.create_task( self.manifest_refresh_service.run(), diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index b08fdfaf7..14d533dcc 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -46,6 +46,7 @@ from ...cloud import support_admin as cloud_support_admin_module from ...cloud.directory import directory_projection_limits_from_config from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.entitlements import EntitlementResolver +from ...cloud.model_catalog import CloudModelCatalogSyncService from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ...api.http.authz import WorkspaceRequiredError @@ -176,6 +177,16 @@ class BuildAppStage(stage.BootingStage): # of repeating tenant validation for every manager. await workspace_service_inst.prime_startup_execution_bindings() + if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment): + raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment') + cloud_model_catalog_service = CloudModelCatalogSyncService( + ap, + deployment.model_catalog_provider, + constants.instance_id, + ) + await cloud_model_catalog_service.initialize() + ap.cloud_model_catalog_service = cloud_model_catalog_service + ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService( ap, workspace_service_inst, diff --git a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py new file mode 100644 index 000000000..62ed06049 --- /dev/null +++ b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py @@ -0,0 +1,112 @@ +"""Cloud Runtime write protection for the managed LangBot Models catalog.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service import model as model_service_module +from langbot.pkg.api.http.service.model import ( + EmbeddingModelsService, + LLMModelsService, + RerankModelsService, + _assert_cloud_managed_provider_mutable, +) +from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER + + +WORKSPACE = 'workspace-a' +PROVIDER = 'managed-provider' +MODEL = 'managed-model' + + +@pytest.mark.asyncio +async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None: + async def managed_provider(_ap, _context, provider_uuid): + assert provider_uuid == PROVIDER + return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER} + + monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider) + application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime'))) + + with pytest.raises(ValueError, match='managed by Cloud'): + await _assert_cloud_managed_provider_mutable( + application, + WORKSPACE, + PROVIDER, + ) + + application.persistence_mgr.mode.value = 'normal' + await _assert_cloud_managed_provider_mutable( + application, + WORKSPACE, + PROVIDER, + ) + + +@pytest.mark.parametrize( + ('service_type', 'create_method', 'model_data'), + [ + (LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}), + (EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}), + (RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}), + ], +) +@pytest.mark.asyncio +async def test_all_model_types_reject_creation_under_managed_provider( + monkeypatch, + service_type, + create_method: str, + model_data: dict, +) -> None: + guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified')) + monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard) + application = SimpleNamespace( + persistence_mgr=SimpleNamespace(), + provider_service=SimpleNamespace( + get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}) + ), + model_mgr=None, + ) + service = service_type(application) + + with pytest.raises(ValueError, match='managed by Cloud'): + await getattr(service, create_method)(WORKSPACE, model_data) + + guard.assert_awaited_once() + + +@pytest.mark.parametrize( + ('service_type', 'get_method', 'write_method', 'payload'), + [ + (LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}), + (LLMModelsService, 'get_llm_model', 'delete_llm_model', None), + (EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}), + (EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None), + (RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}), + (RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None), + ], +) +@pytest.mark.asyncio +async def test_all_model_types_reject_update_and_delete_for_managed_provider( + monkeypatch, + service_type, + get_method: str, + write_method: str, + payload: dict | None, +) -> None: + guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified')) + monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard) + application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime'))) + service = service_type(application) + monkeypatch.setattr( + service, + get_method, + AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}), + ) + + args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload) + with pytest.raises(ValueError, match='managed by Cloud'): + await getattr(service, write_method)(*args) + + guard.assert_awaited_once() diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index 15b995895..fdf8b01bc 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -25,6 +25,7 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.asyncio WORKSPACE_UUID = 'workspace-a' +SYSTEM_REQUESTER = 'space-chat-completions' def _create_mock_provider( @@ -1005,3 +1006,56 @@ class TestProviderSecretRoundtrip: ) ap.persistence_mgr.execute_async.assert_not_awaited() + + +class TestCloudManagedProviderProtection: + @staticmethod + def _service() -> ModelProviderService: + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace( + mode=SimpleNamespace(value='cloud_runtime'), + execute_async=AsyncMock(), + ), + model_mgr=SimpleNamespace(), + ) + return ModelProviderService(ap) + + async def test_cloud_rejects_user_created_system_requester(self): + service = self._service() + + with pytest.raises(ValueError, match='reserved'): + await service.create_provider( + WORKSPACE_UUID, + { + 'name': 'Fake LangBot Models', + 'requester': SYSTEM_REQUESTER, + 'base_url': 'https://example.invalid/v1', + 'api_keys': ['fake'], + }, + ) + + with pytest.raises(ValueError, match='reserved'): + await service.find_or_create_provider( + WORKSPACE_UUID, + SYSTEM_REQUESTER, + 'https://api.langbot.cloud/v1', + ['fake'], + ) + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_cloud_rejects_update_and_delete_of_managed_provider(self): + service = self._service() + service.get_provider = AsyncMock( + return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER} + ) + + with pytest.raises(ValueError, match='managed by Cloud'): + await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'}) + with pytest.raises(ValueError, match='managed by Cloud'): + await service.delete_provider(WORKSPACE_UUID, 'system-provider') + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_oss_does_not_reserve_space_requester(self): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat'))) + service = ModelProviderService(ap) + assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py index e27367d8d..6225ba7b7 100644 --- a/tests/unit_tests/cloud/test_bootstrap.py +++ b/tests/unit_tests/cloud/test_bootstrap.py @@ -66,6 +66,10 @@ class _Provider: def __init__(self): self.manifest_provider = _Manifest() + async def fetch_model_catalog(self, instance_uuid: str): + del instance_uuid + raise AssertionError('not used by bootstrap contract tests') + def bootstrap(self, *, instance_uuid: str, instance_config: dict): del instance_config return VerifiedCloudDeployment( @@ -79,6 +83,7 @@ class _Provider: entitlement_provider=_Entitlements(), directory_provider=_Directory(), manifest_provider=self.manifest_provider, + model_catalog_provider=self, verification_key_id='root-2026', ) diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py new file mode 100644 index 000000000..1a251f1e7 --- /dev/null +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.cloud.model_catalog import ( + CloudModelCatalogSnapshot, + CloudModelCatalogSyncService, + system_model_uuid, + system_provider_uuid, +) +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider +from langbot.pkg.entity.persistence.workspace import Workspace +from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode + + +pytestmark = pytest.mark.asyncio +INSTANCE_UUID = 'instance-model-catalog' +WORKSPACE_A = '00000000-0000-4000-8000-000000000001' +WORKSPACE_B = '00000000-0000-4000-8000-000000000002' +OWNER_A = '10000000-0000-4000-8000-000000000001' +OWNER_B = '10000000-0000-4000-8000-000000000002' + + +class _CatalogProvider: + def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None: + self.snapshot = snapshot + + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + assert instance_uuid == INSTANCE_UUID + return self.snapshot + + +def _snapshot( + *, + key_a: str | None = 'owner-a-key', + model_id: str = 'gpt-test', + include_embedding: bool = True, +) -> CloudModelCatalogSnapshot: + models = [ + { + 'uuid': 'upstream-chat', + 'model_id': model_id, + 'category': 'chat', + 'llm_abilities': ['chat', 'vision'], + 'is_featured': True, + 'featured_order': 7, + } + ] + if include_embedding: + models.append( + { + 'uuid': 'upstream-embedding', + 'model_id': 'embedding-test', + 'category': 'embedding', + } + ) + return CloudModelCatalogSnapshot.model_validate( + { + 'instance_uuid': INSTANCE_UUID, + 'generated_at': datetime.now(UTC), + 'base_url': 'https://api.langbot.cloud/v1/', + 'models': models, + 'workspaces': [ + { + 'workspace_uuid': WORKSPACE_A, + 'owner_account_uuid': OWNER_A, + 'api_key': key_a, + }, + { + 'workspace_uuid': WORKSPACE_B, + 'owner_account_uuid': OWNER_B, + 'api_key': 'owner-b-key', + }, + ], + } + ) + + +async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None: + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}') + manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) + manager.db = SimpleNamespace(get_engine=lambda: engine) + bindings = [ + SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1), + SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1), + ] + workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)) + reload_counter = _AsyncCounter() + runtime_reload = SimpleNamespace(load_models_from_db=reload_counter) + app = SimpleNamespace( + persistence_mgr=manager, + workspace_service=workspace_service, + model_mgr=runtime_reload, + logger=logging.getLogger(__name__), + ) + provider = _CatalogProvider(_snapshot()) + service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID) + + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute( + sqlalchemy.insert(Workspace), + [ + { + 'uuid': WORKSPACE_A, + 'instance_uuid': INSTANCE_UUID, + 'name': 'A', + 'slug': 'a', + 'source': 'cloud_projection', + }, + { + 'uuid': WORKSPACE_B, + 'instance_uuid': INSTANCE_UUID, + 'name': 'B', + 'slug': 'b', + 'source': 'cloud_projection', + }, + ], + ) + await connection.execute( + sqlalchemy.insert(ModelProvider).values( + uuid='custom-provider', + workspace_uuid=WORKSPACE_A, + name='Custom', + requester='openai-chat-completions', + base_url='https://custom.example/v1', + api_keys=['custom-key'], + ) + ) + await connection.execute( + sqlalchemy.insert(LLMModel).values( + uuid='custom-model', + workspace_uuid=WORKSPACE_A, + name='custom-model', + provider_uuid='custom-provider', + abilities=['chat'], + extra_args={}, + prefered_ranking=0, + ) + ) + + first = await service.sync_once() + assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0} + assert reload_counter.calls == 1 + + async with engine.connect() as connection: + providers = ( + await connection.execute( + sqlalchemy.select( + ModelProvider.uuid, + ModelProvider.workspace_uuid, + ModelProvider.api_keys, + ).where(ModelProvider.requester == 'space-chat-completions') + ) + ).all() + assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B} + assert {item.uuid for item in providers} == { + system_provider_uuid(WORKSPACE_A), + system_provider_uuid(WORKSPACE_B), + } + assert {item.workspace_uuid: item.api_keys for item in providers} == { + WORKSPACE_A: ['owner-a-key'], + WORKSPACE_B: ['owner-b-key'], + } + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3 + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2 + + second = await service.sync_once() + assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0} + assert reload_counter.calls == 1 + + provider.snapshot = _snapshot( + key_a='new-owner-key', + model_id='gpt-renamed', + include_embedding=False, + ) + third = await service.sync_once() + assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2} + assert reload_counter.calls == 2 + + async with engine.connect() as connection: + provider_a_keys = await connection.scalar( + sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A)) + ) + assert provider_a_keys == ['new-owner-key'] + system_model_names = ( + ( + await connection.execute( + sqlalchemy.select(LLMModel.name).where( + LLMModel.provider_uuid.in_( + [system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)] + ) + ) + ) + ) + .scalars() + .all() + ) + assert set(system_model_names) == {'gpt-renamed'} + assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0 + assert ( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(ModelProvider) + .where(ModelProvider.uuid == 'custom-provider') + ) + == 1 + ) + assert ( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(LLMModel) + .where(LLMModel.uuid == 'custom-model') + ) + == 1 + ) + + provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False) + fourth = await service.sync_once() + assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0} + assert reload_counter.calls == 3 + async with engine.connect() as connection: + provider_a_keys = await connection.scalar( + sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A)) + ) + assert provider_a_keys == [] + finally: + await engine.dispose() + + +def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None: + assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A) + assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B) + assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream') + snapshot = _snapshot() + assert 'owner-a-key' not in repr(snapshot) + + +async def test_snapshot_must_cover_every_active_workspace() -> None: + snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]}) + app = SimpleNamespace( + workspace_service=SimpleNamespace( + list_active_execution_bindings=lambda: _async_value( + [SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)] + ) + ), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID) + with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'): + await service.sync_once() + + +async def _async_value(value): + return value + + +class _AsyncCounter: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self) -> None: + self.calls += 1 + + +async def test_partial_workspace_failure_reloads_already_committed_changes() -> None: + bindings = [ + SimpleNamespace(workspace_uuid=WORKSPACE_A), + SimpleNamespace(workspace_uuid=WORKSPACE_B), + ] + reload_counter = _AsyncCounter() + app = SimpleNamespace( + workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)), + model_mgr=SimpleNamespace(load_models_from_db=reload_counter), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID) + calls = 0 + + async def sync_workspace(*_args): + nonlocal calls + calls += 1 + if calls == 1: + return {'created': 1, 'updated': 0, 'deleted': 0} + raise RuntimeError('second Workspace failed') + + service._sync_workspace = sync_workspace # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match='second Workspace failed'): + await service.sync_once() + assert reload_counter.calls == 1 + + +async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None: + bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)] + + class _FlakyReload: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self) -> None: + self.calls += 1 + if self.calls == 1: + raise RuntimeError('reload failed') + + runtime_reload = _FlakyReload() + app = SimpleNamespace( + workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)), + model_mgr=SimpleNamespace(load_models_from_db=runtime_reload), + logger=logging.getLogger(__name__), + ) + service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID) + calls = 0 + + async def sync_workspace(*_args): + nonlocal calls + calls += 1 + if calls == 1: + return {'created': 1, 'updated': 0, 'deleted': 0} + return {'created': 0, 'updated': 0, 'deleted': 0} + + service._sync_workspace = sync_workspace # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match='reload failed'): + await service.sync_once() + summary = await service.sync_once() + assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0} + assert runtime_reload.calls == 2 + + +async def test_background_sync_log_redacts_exception_message(caplog) -> None: + secret = 'owner-secret-api-key' + attempted = asyncio.Event() + + class _FailingProvider: + async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot: + del instance_uuid + attempted.set() + raise RuntimeError(f'database parameters include {secret}') + + app = SimpleNamespace(logger=logging.getLogger(__name__)) + service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID) + service.sync_interval_seconds = 0.001 + task = asyncio.create_task(service.run()) + try: + await asyncio.wait_for(attempted.wait(), timeout=1) + await asyncio.sleep(0.01) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert secret not in caplog.text + assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text diff --git a/uv.lock b/uv.lock index a23277e25..5381912a3 100644 --- a/uv.lock +++ b/uv.lock @@ -1999,7 +1999,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.6" +version = "4.10.7" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index cfce808fd..e73e2dea4 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -245,8 +245,7 @@ function SpaceOAuthCallbackContent() { const workspaceUuid = directLaunchFragmentRef.current.workspaceUuid ?? searchParams.get('workspace_uuid'); - const launchAssertion = - directLaunchFragmentRef.current.launchAssertion; + const launchAssertion = directLaunchFragmentRef.current.launchAssertion; if (error) { setStatus('error'); diff --git a/web/tests/unit/space-launch-callback.test.mjs b/web/tests/unit/space-launch-callback.test.mjs index ff4ac419c..7debabf6c 100644 --- a/web/tests/unit/space-launch-callback.test.mjs +++ b/web/tests/unit/space-launch-callback.test.mjs @@ -13,6 +13,12 @@ test('direct launch assertion is fragment-only and removed before exchange', () const clearIndex = source.indexOf('window.history.replaceState'); const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex); assert.ok(readIndex >= 0, 'fragment assertion read is missing'); - assert.ok(clearIndex > readIndex, 'URL fragment is not cleared after copying the assertion'); - assert.ok(exchangeIndex > clearIndex, 'assertion exchange starts before the fragment is cleared'); + assert.ok( + clearIndex > readIndex, + 'URL fragment is not cleared after copying the assertion', + ); + assert.ok( + exchangeIndex > clearIndex, + 'assertion exchange starts before the fragment is cleared', + ); }); From e36e3aaea85a1c1ea0ae08dc4efbf48744c12c4b Mon Sep 17 00:00:00 2001 From: Hyu Date: Sat, 1 Aug 2026 18:50:47 +0800 Subject: [PATCH 22/33] fix(cloud): accept null model abilities (#2377) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/cloud/model_catalog.py | 5 +++++ tests/unit_tests/cloud/test_model_catalog.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py index 48b80b24b..a76376505 100644 --- a/src/langbot/pkg/cloud/model_catalog.py +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -32,6 +32,11 @@ class CloudModelCatalogItem(BaseModel): is_featured: bool = False featured_order: int = 0 + @field_validator('llm_abilities', mode='before') + @classmethod + def normalize_missing_abilities(cls, value: Any) -> Any: + return () if value is None else value + @field_validator('llm_abilities') @classmethod def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]: diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py index 1a251f1e7..6f4b0a275 100644 --- a/tests/unit_tests/cloud/test_model_catalog.py +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -84,6 +84,15 @@ def _snapshot( ) +async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None: + payload = _snapshot().model_dump(mode='json') + payload['models'][0]['llm_abilities'] = None + + snapshot = CloudModelCatalogSnapshot.model_validate(payload) + + assert snapshot.models[0].llm_abilities == () + + async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None: engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}') manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) From c5aada494d8798dff119357410d8e128ade17a06 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 00:33:46 +0800 Subject: [PATCH 23/33] fix(cloud): treat workspace owners as Space-bound (#2378) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 13 ++++++++-- .../integration/api/test_user_space_oauth.py | 25 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8b86ba361..560f5988c 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -285,8 +285,17 @@ class UserRouterGroup(group.RouterGroup): request_context.workspace_uuid, ) owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid) - owner_space_bound = bool(owner and owner.space_account_uuid) - credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + owner_has_local_space_credentials = bool(owner and owner.space_account_uuid) + # Cloud Accounts authenticate through LangBot Account, so every projected + # Workspace owner is already bound even when this Core has no local OAuth + # token row (model billing uses the owner's control-plane API key). + owner_space_bound = cloud_mode or owner_has_local_space_credentials + credits = ( + await self.ap.space_service.get_credits(owner.user) + if owner is not None and owner.space_account_uuid + else None + ) return self.success( data={ 'credits': credits, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index deb1e93e2..35e048053 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -272,9 +272,10 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): '/api/v1/user/space-credits', headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, ) + payload = await response.get_json() assert response.status_code == 200 - assert (await response.get_json())['data'] == { + assert payload['data'] == { 'credits': 25000, 'owner_space_bound': True, 'is_workspace_owner': True, @@ -282,6 +283,28 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): application.space_service.get_credits.assert_awaited_once_with('owner@example.com') +@pytest.mark.asyncio +async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.user_service.get_workspace_owner = AsyncMock(return_value=None) + application.space_service.get_credits = AsyncMock() + + response = await client.get( + '/api/v1/user/space-credits', + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, + ) + payload = await response.get_json() + + assert response.status_code == 200 + assert payload['data'] == { + 'credits': None, + 'owner_space_bound': True, + 'is_workspace_owner': True, + } + application.space_service.get_credits.assert_not_awaited() + + @pytest.mark.asyncio async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): application, client = space_oauth_api From 0ccbcd5f5fc028b4b57ce4fd202c1bc153890c1c Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 00:56:20 +0800 Subject: [PATCH 24/33] fix(migrations): preserve published Cloud revision head (#2375) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../versions/0016_space_launch_replay.py | 57 +++++++++++++++++++ .../versions/0018_merge_launch_replay.py | 21 +++++++ .../persistence/test_migrations.py | 12 ++++ 3 files changed, 90 insertions(+) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py new file mode 100644 index 000000000..33f44c19c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_space_launch_replay.py @@ -0,0 +1,57 @@ +"""add durable replay protection for signed Space launch assertions + +Revision ID: 0016_space_launch_replay +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0016_space_launch_replay' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + +_TABLE = 'space_launch_assertion_consumptions' +_POLICY = 'langbot_directory_projection' +_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')" + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in set(sa.inspect(conn).get_table_names()): + op.create_table( + _TABLE, + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('instance_uuid', 'jti'), + ) + op.create_index( + 'ix_space_launch_assertion_consumptions_expiry', + _TABLE, + ['instance_uuid', 'expires_at'], + unique=False, + ) + if conn.dialect.name == 'postgresql': + table = conn.dialect.identifier_preparer.quote(_TABLE) + policy = conn.dialect.identifier_preparer.quote(_POLICY) + expression = f'instance_uuid::text = {_SETTING}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()): + op.drop_table(_TABLE) diff --git a/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py new file mode 100644 index 000000000..68f4e1007 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0018_merge_launch_replay.py @@ -0,0 +1,21 @@ +"""merge the published Space launch replay and main migration branches + +Revision ID: 0018_merge_launch_replay +Revises: 0016_space_launch_replay, 0017_oss_workspace_identity +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +revision = '0018_merge_launch_replay' +down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index c1a455fb1..3447c9320 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -95,6 +95,18 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" + @pytest.mark.asyncio + async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): + """A database released at the production-only 0016 head must remain upgradable.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == _get_script_head() + assert _get_script_head() == '0018_merge_launch_replay' + @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): """ From e2331c49673260c2970726337602f75e5d0cfd29 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 01:34:11 +0800 Subject: [PATCH 25/33] fix(cloud): restore plugins and pipeline execution (#2379) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- pyproject.toml | 2 +- src/langbot/pkg/pipeline/controller.py | 4 +-- .../pipeline/test_controller_tenancy.py | 29 +++++++++++++++++++ uv.lock | 6 ++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28890f610..63eb27a84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162", + "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@101e453e916b39465a6294d6471c9eaae8725d5c", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py index d14288c29..1502ae4de 100644 --- a/src/langbot/pkg/pipeline/controller.py +++ b/src/langbot/pkg/pipeline/controller.py @@ -132,9 +132,7 @@ class Controller: break - if selected_query: # 找到了 - queries.remove(selected_query) - else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 + if not selected_query: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 await self.ap.query_pool.condition.wait() continue diff --git a/tests/unit_tests/pipeline/test_controller_tenancy.py b/tests/unit_tests/pipeline/test_controller_tenancy.py index b99e54bb2..ac999e56b 100644 --- a/tests/unit_tests/pipeline/test_controller_tenancy.py +++ b/tests/unit_tests/pipeline/test_controller_tenancy.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind from langbot.pkg.pipeline.controller import Controller +from langbot.pkg.pipeline.pool import QueryPool from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError @@ -143,3 +144,31 @@ async def test_controller_revalidates_generation_before_running_pipeline( runtime_pipeline.run.assert_awaited_once_with(sample_query) query_pool.remove_query.assert_awaited_once_with(sample_query) session._semaphore.release.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query): + query_pool = QueryPool() + query_pool.queries.append(sample_query) + mock_app.query_pool = query_pool + mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1))) + + scheduler_errors: list[str] = [] + + def stop_on_scheduler_error(message): + scheduler_errors.append(str(message)) + raise asyncio.CancelledError + + def stop_after_scheduling(process_coro, **_kwargs): + process_coro.close() + raise asyncio.CancelledError + + mock_app.logger.error.side_effect = stop_on_scheduler_error + mock_app.task_mgr.create_task.side_effect = stop_after_scheduling + controller = Controller(mock_app) + + with pytest.raises(asyncio.CancelledError): + await controller.consumer() + + assert scheduler_errors == [] + assert query_pool.queries == [] diff --git a/uv.lock b/uv.lock index 5381912a3..c2f7bef76 100644 --- a/uv.lock +++ b/uv.lock @@ -2116,7 +2116,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2182,8 +2182,8 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.4.18" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162#1d65ed301a6afc52150a998043f73cd6032c8162" } +version = "0.5.0" +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c#101e453e916b39465a6294d6471c9eaae8725d5c" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, From e9c9e896c6cd460863e06b98e94d5bbc42a2e526 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 01:49:17 +0800 Subject: [PATCH 26/33] fix(cloud): preserve pipeline routing in debug chat (#2380) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/platform/sources/websocket_adapter.py | 53 +++++++++++-------- .../test_websocket_session_isolation.py | 43 +++++++++++++++ 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 6176e2d93..0752225c6 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -707,28 +707,37 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) if len(listener_tasks) >= 100: await self.logger.warning('WebSocket inbound listener capacity reached; dropping message') return - token = _current_pipeline_uuid.set(pipeline_uuid) - try: - task_manager = getattr(self.ap, 'task_mgr', None) - if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list): - listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter)) - else: - listener_task = task_manager.create_task( - listeners[event.__class__](event, callback_adapter), - kind='websocket-message', - name=f'websocket-message-{connection.connection_id}', - scopes=[ - core_entities.LifecycleControlScope.APPLICATION, - core_entities.LifecycleControlScope.PLATFORM, - ], - instance_uuid=connection.instance_uuid, - workspace_uuid=connection.workspace_uuid, - placement_generation=connection.placement_generation, - ).task - listener_tasks.add(listener_task) - listener_task.add_done_callback(self._listener_task_done) - finally: - _current_pipeline_uuid.reset(token) + listener = typing.cast( + typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]], + listeners[event.__class__], + ) + + async def run_listener(): + token = _current_pipeline_uuid.set(pipeline_uuid) + try: + await listener(event, callback_adapter) + finally: + _current_pipeline_uuid.reset(token) + + listener_coro = run_listener() + task_manager = getattr(self.ap, 'task_mgr', None) + if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list): + listener_task = asyncio.create_task(listener_coro) + else: + listener_task = task_manager.create_task( + listener_coro, + kind='websocket-message', + name=f'websocket-message-{connection.connection_id}', + scopes=[ + core_entities.LifecycleControlScope.APPLICATION, + core_entities.LifecycleControlScope.PLATFORM, + ], + instance_uuid=connection.instance_uuid, + workspace_uuid=connection.workspace_uuid, + placement_generation=connection.placement_generation, + ).task + listener_tasks.add(listener_task) + listener_task.add_done_callback(self._listener_task_done) def get_websocket_messages( self, diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py index 682a42184..958ba5888 100644 --- a/tests/unit_tests/platform/test_websocket_session_isolation.py +++ b/tests/unit_tests/platform/test_websocket_session_isolation.py @@ -1,6 +1,7 @@ """Regression tests for isolated embed-widget conversations.""" import asyncio +import contextvars from pathlib import Path from unittest.mock import AsyncMock, Mock @@ -204,6 +205,48 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch): assert received[0].sender.id == f'websocket_pipeline-1:{session_id}' +@pytest.mark.asyncio +async def test_pipeline_override_survives_detached_listener_task(monkeypatch): + manager = WebSocketConnectionManager() + connection = await manager.add_connection( + websocket=Mock(), + scope=SCOPE_A, + pipeline_uuid='pipeline-1', + session_type='person', + ) + monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager) + + class DetachedTaskManager: + def __init__(self): + self.tasks = [] + + def create_task(self, coro, **_kwargs): + task = asyncio.create_task(coro, context=contextvars.Context()) + self.tasks.append(task) + return Mock(task=task) + + task_manager = DetachedTaskManager() + adapter = WebSocketAdapter.model_construct( + ap=Mock(task_mgr=task_manager), + logger=_adapter_logger(), + ) + adapter.websocket_person_session = WebSocketSession(id='person') + adapter.websocket_group_session = WebSocketSession(id='group') + pipeline_overrides = [] + + async def listener(_event, callback_adapter): + pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override()) + + adapter.listeners = {platform_events.FriendMessage: listener} + await adapter.handle_websocket_message( + connection, + {'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False}, + ) + await asyncio.gather(*task_manager.tasks) + + assert pipeline_overrides == ['pipeline-1'] + + @pytest.mark.asyncio async def test_embed_group_event_uses_stable_session_launcher(monkeypatch): manager = WebSocketConnectionManager() From a67728c1630f6b331aa6e60055bcc94ab083f5ca Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 16:39:14 +0800 Subject: [PATCH 27/33] fix cloud monitoring and invitation sign-in (#2381) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/persistence/tenant_uow.py | 2 +- .../unit_tests/persistence/test_tenant_uow.py | 1 + web/src/app/auth/space/callback/page.tsx | 28 ++++++- web/tests/e2e/invitations.spec.ts | 84 +++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 53be402fa..0e460f335 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = { 'now': sqlalchemy.sql.functions.now, 'sum': sqlalchemy.sql.functions.sum, } -_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'}) +_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_STATEMENT_TYPES = ( sqlalchemy.sql.dml.UpdateBase, diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py index 7be62bf2e..bc8369f08 100644 --- a/tests/unit_tests/persistence/test_tenant_uow.py +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -961,6 +961,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql( sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))), sa.select( sa.func.now(), + sa.func.date_trunc('hour', sa.column('timestamp')), sa.func.length(sa.literal('value')), sa.func.nullif(sa.literal('value'), sa.literal('')), ), diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index e73e2dea4..c2c142589 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -5,6 +5,7 @@ import { beginAuthenticatedSession, beginSupportAdminSession, bootstrapWorkspaceSession, + clearPendingInvitationToken, getPendingInvitationToken, } from '@/app/infra/http'; import { toast } from 'sonner'; @@ -112,8 +113,31 @@ function SpaceOAuthCallbackContent() { } beginAuthenticatedSession(response.token, response.user); - if (getPendingInvitationToken()) { - navigate('/invitations/accept', { replace: true }); + const invitationToken = getPendingInvitationToken(); + if (invitationToken) { + let invitation; + try { + invitation = + await httpClient.acceptWorkspaceInvitation(invitationToken); + } catch (error) { + const code = (error as { code?: string }).code; + const path = code + ? `/invitations/accept?error=${encodeURIComponent(code)}` + : '/invitations/accept'; + navigate(path, { replace: true }); + return; + } + + beginAuthenticatedSession(invitation.token, response.user); + clearPendingInvitationToken(); + const workspaceResult = await bootstrapWorkspaceSession({ + preferredWorkspaceUuid: invitation.workspace_uuid, + }); + if (workspaceResult.status === 'unavailable') { + navigate('/workspace-unavailable', { replace: true }); + return; + } + navigate('/home', { replace: true }); return; } const workspaceResult = await bootstrapWorkspaceSession({ diff --git a/web/tests/e2e/invitations.spec.ts b/web/tests/e2e/invitations.spec.ts index 5e8b217ec..7cd1cc1cc 100644 --- a/web/tests/e2e/invitations.spec.ts +++ b/web/tests/e2e/invitations.spec.ts @@ -165,3 +165,87 @@ test('an authenticated OSS invitation requires logout before registration', asyn invitation: 'logout-invitation', }); }); + +test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: false, + storage: { + token: 'stale-other-account-token', + userEmail: 'other@example.com', + }, + }); + await page.addInitScript(() => { + sessionStorage.setItem( + 'langbot_pending_invitation_token', + 'matching-invitation', + ); + }); + + await page.route('**/api/v1/user/space/callback', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + token: 'fresh-invited-account-token', + user: 'invited@example.com', + }, + msg: 'ok', + }), + }); + }); + await page.route('**/api/v1/user/info', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + account_uuid: 'invited-account', + user: 'invited@example.com', + account_type: 'space', + has_password: false, + }, + msg: 'ok', + }), + }); + }); + + let acceptanceAuthorization = ''; + await page.route('**/api/v1/invitations/accept', async (route) => { + acceptanceAuthorization = route.request().headers().authorization ?? ''; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + token: 'accepted-invited-account-token', + workspace_uuid: 'workspace-playwright', + }, + msg: 'ok', + }), + }); + }); + + await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state'); + + await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, { + timeout: 5_000, + }); + expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token'); + expect( + await page.evaluate(() => ({ + token: localStorage.getItem('token'), + userEmail: localStorage.getItem('userEmail'), + invitation: sessionStorage.getItem('langbot_pending_invitation_token'), + })), + ).toEqual({ + token: 'accepted-invited-account-token', + userEmail: 'invited@example.com', + invitation: null, + }); +}); From a7a7218afec40ee85e4d7708a3ef32137c0eafa3 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:08:49 +0000 Subject: [PATCH 28/33] fix(cloud): accept invitations with current account --- .../pkg/api/http/controller/groups/user.py | 4 +- tests/integration/api/test_smoke.py | 24 ++++++ web/src/app/infra/http/BackendClient.ts | 1 + web/src/app/invitations/accept/page.tsx | 20 ++++- web/tests/e2e/invitations.spec.ts | 77 +++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 560f5988c..49b59906b 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -311,8 +311,10 @@ class UserRouterGroup(group.RouterGroup): return self.success(data={'initialized': False}) capabilities = await self.ap.user_service.get_login_capabilities() - if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if cloud_mode: capabilities['password_login_enabled'] = False + capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 9c2927503..dfd5054f2 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q from __future__ import annotations +from types import SimpleNamespace + import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -304,12 +306,34 @@ class TestUserInitEndpoint: data = await response.get_json() assert data['data'] == { 'initialized': True, + 'authenticated_invitation_acceptance_enabled': False, 'password_login_enabled': True, 'space_login_enabled': False, } fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() + @pytest.mark.asyncio + async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud( + self, quart_test_client, fake_api_app + ): + fake_api_app.deployment = SimpleNamespace(mode='cloud') + fake_api_app.user_service.is_initialized.return_value = True + fake_api_app.user_service.get_login_capabilities = AsyncMock( + return_value={'password_login_enabled': True, 'space_login_enabled': True} + ) + + response = await quart_test_client.get('/api/v1/user/account-info') + + assert response.status_code == 200 + data = await response.get_json() + assert data['data'] == { + 'initialized': True, + 'authenticated_invitation_acceptance_enabled': True, + 'password_login_enabled': False, + 'space_login_enabled': True, + } + @pytest.mark.asyncio async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch): fake_api_app.user_service.is_initialized.return_value = True diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 63d6f37c8..03e0899f6 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1179,6 +1179,7 @@ export class BackendClient extends BaseHttpClient { public getAccountInfo(): Promise<{ initialized: boolean; + authenticated_invitation_acceptance_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; }> { diff --git a/web/src/app/invitations/accept/page.tsx b/web/src/app/invitations/accept/page.tsx index e8d412ecc..0d83331b5 100644 --- a/web/src/app/invitations/accept/page.tsx +++ b/web/src/app/invitations/accept/page.tsx @@ -93,6 +93,10 @@ export default function AcceptInvitationPage() { const [confirmPassword, setConfirmPassword] = useState(''); const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(false); + const [ + authenticatedInvitationAcceptanceEnabled, + setAuthenticatedInvitationAcceptanceEnabled, + ] = useState(false); useEffect(() => { const handleHashChange = () => setInvitationHash(window.location.hash); @@ -113,6 +117,9 @@ export default function AcceptInvitationPage() { .getAccountInfo() .then((info) => { setPasswordRegistrationEnabled(info.password_login_enabled !== false); + setAuthenticatedInvitationAcceptanceEnabled( + info.authenticated_invitation_acceptance_enabled === true, + ); }) .catch(() => setPasswordRegistrationEnabled(false)); if (!invitationToken) { @@ -304,7 +311,18 @@ export default function AcceptInvitationPage() { )} - {hasLoginToken ? ( + {hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( + + ) : hasLoginToken ? (
{t('workspace.authenticatedInvitationNotice')} diff --git a/web/tests/e2e/invitations.spec.ts b/web/tests/e2e/invitations.spec.ts index 7cd1cc1cc..b036838e2 100644 --- a/web/tests/e2e/invitations.spec.ts +++ b/web/tests/e2e/invitations.spec.ts @@ -166,6 +166,83 @@ test('an authenticated OSS invitation requires logout before registration', asyn }); }); +test('an authenticated Cloud Account can accept its invitation directly', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { + token: 'invited-account-token', + userEmail: 'invited@example.com', + }, + }); + await page.route('**/api/v1/user/account-info', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + initialized: true, + authenticated_invitation_acceptance_enabled: true, + password_login_enabled: false, + space_login_enabled: true, + }, + msg: 'ok', + }), + }); + }); + await page.route('**/api/v1/invitations/inspect', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + invitation: { + uuid: 'cloud-invitation', + workspace_uuid: 'workspace-playwright', + normalized_email: 'invited@example.com', + role: 'viewer', + status: 'pending', + }, + workspace: { + uuid: 'workspace-playwright', + name: 'Playwright Workspace', + }, + }, + msg: 'ok', + }), + }); + }); + + let acceptanceAuthorization = ''; + await page.route('**/api/v1/invitations/accept', async (route) => { + acceptanceAuthorization = route.request().headers().authorization ?? ''; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + token: 'accepted-cloud-account-token', + workspace_uuid: 'workspace-playwright', + }, + msg: 'ok', + }), + }); + }); + + await page.goto('/invitations/accept#token=cloud-invitation'); + + await expect( + page.getByRole('button', { name: 'Accept Invitation' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Accept Invitation' }).click(); + await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/); + expect(acceptanceAuthorization).toBe('Bearer invited-account-token'); +}); + test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({ page, }) => { From 1e6e4c0ca728c564e3755faef896a7e20316398e Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 3 Aug 2026 02:14:43 +0800 Subject: [PATCH 29/33] fix(cloud): show owner model balance and enforce single owner (#2384) (#2385) * fix(cloud): show owner model balance and enforce single owner * fix(migrations): create owner index idempotently --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/authz.py | 2 - src/langbot/pkg/api/http/controller/group.py | 1 - .../pkg/api/http/controller/groups/user.py | 18 +++- src/langbot/pkg/cloud/model_catalog.py | 7 ++ .../pkg/entity/persistence/workspace.py | 7 ++ .../versions/0019_single_workspace_owner.py | 80 ++++++++++++++ src/langbot/pkg/workspace/collaboration.py | 28 +---- .../api/test_support_admin_launch.py | 1 - .../integration/api/test_user_space_oauth.py | 5 +- tests/integration/api/test_workspaces.py | 9 ++ .../persistence/test_migrations.py | 2 +- .../test_single_owner_migration.py | 100 ++++++++++++++++++ tests/unit_tests/api/http/test_authz.py | 3 +- tests/unit_tests/cloud/test_model_catalog.py | 6 ++ .../workspace/test_workspace_collaboration.py | 29 ++--- .../models-dialog/components/ProviderCard.tsx | 30 +++--- .../WorkspaceSettingsPanel.tsx | 6 -- web/tests/e2e/fixtures/langbot-api.ts | 1 - .../unit/oss-account-space-billing.test.mjs | 10 ++ 19 files changed, 274 insertions(+), 71 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py create mode 100644 tests/integration/persistence/test_single_owner_migration.py diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py index 225e30aa8..7bfc8dbe3 100644 --- a/src/langbot/pkg/api/http/authz.py +++ b/src/langbot/pkg/api/http/authz.py @@ -19,7 +19,6 @@ class Permission(enum.StrEnum): WORKSPACE_VIEW = 'workspace.view' WORKSPACE_UPDATE = 'workspace.update' WORKSPACE_DELETE = 'workspace.delete' - OWNER_TRANSFER = 'owner.transfer' MEMBER_VIEW = 'member.view' MEMBER_INVITE = 'member.invite' MEMBER_UPDATE_ROLE = 'member.update_role' @@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType( if permission not in { Permission.WORKSPACE_DELETE, - Permission.OWNER_TRANSFER, Permission.BILLING_LINK_MANAGE, } ), diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 0917c6c6d..6459ecec7 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -62,7 +62,6 @@ class AuthType(enum.Enum): _SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( { - Permission.OWNER_TRANSFER.value, Permission.MEMBER_VIEW.value, Permission.MEMBER_INVITE.value, Permission.MEMBER_UPDATE_ROLE.value, diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 49b59906b..e8e450911 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -291,11 +291,19 @@ class UserRouterGroup(group.RouterGroup): # Workspace owner is already bound even when this Core has no local OAuth # token row (model billing uses the owner's control-plane API key). owner_space_bound = cloud_mode or owner_has_local_space_credentials - credits = ( - await self.ap.space_service.get_credits(owner.user) - if owner is not None and owner.space_account_uuid - else None - ) + if cloud_mode: + catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None) + credits = ( + catalog_service.get_workspace_credits(access.workspace.uuid) + if catalog_service is not None + else None + ) + else: + credits = ( + await self.ap.space_service.get_credits(owner.user) + if owner is not None and owner.space_account_uuid + else None + ) return self.success( data={ 'credits': credits, diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py index a76376505..056159e0a 100644 --- a/src/langbot/pkg/cloud/model_catalog.py +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -53,6 +53,7 @@ class CloudWorkspaceModelBilling(BaseModel): workspace_uuid: str = Field(min_length=36, max_length=36) owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36) api_key: SecretStr | None = None + credits: int | None = None @field_validator('workspace_uuid') @classmethod @@ -149,6 +150,11 @@ class CloudModelCatalogSyncService: # convergence marker so a failed runtime reload is retried even when the # following database reconciliation is a no-op. self._runtime_reload_pending = False + self._workspace_credits: dict[str, int | None] = {} + + def get_workspace_credits(self, workspace_uuid: str) -> int | None: + """Return the latest signed owner-credit projection for a Workspace.""" + return self._workspace_credits.get(str(uuid.UUID(workspace_uuid))) async def initialize(self) -> None: await self.sync_once(reload_runtime=False) @@ -198,6 +204,7 @@ class CloudModelCatalogSyncService: self._runtime_reload_pending = True for key in ('created', 'updated', 'deleted'): summary[key] += counts[key] + self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits except Exception as exc: sync_error = exc finally: diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py index ca3743d4b..822c8e9d4 100644 --- a/src/langbot/pkg/entity/persistence/workspace.py +++ b/src/langbot/pkg/entity/persistence/workspace.py @@ -163,6 +163,13 @@ class WorkspaceMembership(Base): __table_args__ = ( sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'), sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'), + sqlalchemy.Index( + 'uq_workspace_memberships_one_active_owner', + 'workspace_uuid', + unique=True, + sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"), + ), sqlalchemy.CheckConstraint( "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')", name='ck_workspace_memberships_role', diff --git a/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py new file mode 100644 index 000000000..55e4e08f2 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0019_single_workspace_owner.py @@ -0,0 +1,80 @@ +"""enforce one active owner per Workspace + +Revision ID: 0019_single_workspace_owner +Revises: 0018_merge_launch_replay +Create Date: 2026-08-02 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0019_single_workspace_owner' +down_revision = '0018_merge_launch_replay' +branch_labels = None +depends_on = None + +_INDEX_NAME = 'uq_workspace_memberships_one_active_owner' + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + + # Ownership transfer used to promote a second member without demoting the + # original owner. Preserve the Workspace creator where possible and demote + # every historical extra owner before installing the database invariant. + op.execute( + sa.text( + """ + WITH ranked_owners AS ( + SELECT membership.uuid, + ROW_NUMBER() OVER ( + PARTITION BY membership.workspace_uuid + ORDER BY + CASE + WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0 + ELSE 1 + END, + COALESCE(membership.joined_at, membership.created_at), + membership.uuid + ) AS owner_rank + FROM workspace_memberships AS membership + JOIN workspaces AS workspace + ON workspace.uuid = membership.workspace_uuid + WHERE membership.role = 'owner' + AND membership.status = 'active' + ) + UPDATE workspace_memberships + SET role = 'admin' + WHERE uuid IN ( + SELECT uuid + FROM ranked_owners + WHERE owner_rank > 1 + ) + """ + ) + ) + # Fresh installations may already have this index because SQLAlchemy + # metadata is created before Alembic advances the revision marker. + op.execute( + sa.text( + 'CREATE UNIQUE INDEX IF NOT EXISTS ' + 'uq_workspace_memberships_one_active_owner ' + 'ON workspace_memberships (workspace_uuid) ' + "WHERE role = 'owner' AND status = 'active'" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')} + if _INDEX_NAME in index_names: + op.drop_index(_INDEX_NAME, table_name='workspace_memberships') diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index bcebe47c4..3ffbb6999 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -606,6 +606,8 @@ class WorkspaceCollaborationService: ) -> WorkspaceMembership: if role not in {item.value for item in MembershipRole}: raise MembershipPermissionError('Unknown Workspace role') + if role == MembershipRole.OWNER.value: + raise MembershipPermissionError('Workspace ownership cannot be transferred') async def operation(active_session: AsyncSession) -> WorkspaceMembership: await self._require_active_workspace(active_session, workspace_uuid) @@ -617,8 +619,8 @@ class WorkspaceCollaborationService: target_account_uuid, ) self._require_can_manage_target(persisted_actor, target, new_role=role) - if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + if target.role == MembershipRole.OWNER.value: + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.role = role await active_session.flush() return target @@ -644,7 +646,7 @@ class WorkspaceCollaborationService: ) self._require_can_manage_target(persisted_actor, target) if target.role == MembershipRole.OWNER.value: - await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) + raise LastOwnerError('The Workspace owner cannot be removed or demoted') target.status = MembershipStatus.REMOVED.value await active_session.flush() return target @@ -751,26 +753,6 @@ class WorkspaceCollaborationService: raise WorkspaceNotFoundError('Workspace not found') return persisted_actor - async def _require_another_owner( - self, - session: AsyncSession, - workspace_uuid: str, - excluded_account_uuid: str, - ) -> None: - owners = ( - await session.scalars( - sqlalchemy.select(WorkspaceMembership) - .where( - WorkspaceMembership.workspace_uuid == workspace_uuid, - WorkspaceMembership.status == MembershipStatus.ACTIVE.value, - WorkspaceMembership.role == MembershipRole.OWNER.value, - ) - .with_for_update() - ) - ).all() - if not any(owner.account_uuid != excluded_account_uuid for owner in owners): - raise LastOwnerError('The last Workspace owner cannot be removed or demoted') - def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None: if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value: raise WorkspaceNotFoundError('Workspace not found') diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index 94647a7f1..e71cb4374 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s assert Permission.RESOURCE_MANAGE.value in permissions assert not permissions.intersection( { - Permission.OWNER_TRANSFER.value, Permission.MEMBER_VIEW.value, Permission.MEMBER_INVITE.value, Permission.MEMBER_UPDATE_ROLE.value, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 35e048053..16c86156d 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -289,6 +289,9 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau application.deployment.mode = 'cloud' application.user_service.get_workspace_owner = AsyncMock(return_value=None) application.space_service.get_credits = AsyncMock() + application.cloud_model_catalog_service = SimpleNamespace( + get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None + ) response = await client.get( '/api/v1/user/space-credits', @@ -298,7 +301,7 @@ async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oau assert response.status_code == 200 assert payload['data'] == { - 'credits': None, + 'credits': 25000, 'owner_space_bound': True, 'is_workspace_owner': True, } diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 7547f4800..d3a70a56d 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac workspace_uuid = current['workspace']['uuid'] assert current['membership']['role'] == 'owner' assert 'member.invite' in current['permissions'] + assert 'owner.transfer' not in current['permissions'] invite_response = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', @@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac assert member_current['membership']['role'] == 'viewer' assert 'member.invite' not in member_current['permissions'] + transfer_response = await client.patch( + f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}', + headers=_auth(owner_token, workspace_uuid), + json={'role': 'owner'}, + ) + assert transfer_response.status_code == 403 + assert (await transfer_response.get_json())['code'] == 'permission_denied' + forbidden_invite = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', headers=_auth(member_token, workspace_uuid), diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 3447c9320..933d6fcd4 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -105,7 +105,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0018_merge_launch_replay' + assert _get_script_head() == '0019_single_workspace_owner' @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): diff --git a/tests/integration/persistence/test_single_owner_migration.py b/tests/integration/persistence/test_single_owner_migration.py new file mode 100644 index 000000000..249759d72 --- /dev/null +++ b/tests/integration/persistence/test_single_owner_migration.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership +from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade + + +@pytest.mark.asyncio +async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner')) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + workspace_uuid = '00000000-0000-4000-8000-000000000001' + creator_uuid = '00000000-0000-4000-8000-000000000010' + promoted_uuid = '00000000-0000-4000-8000-000000000020' + async with session_factory() as session: + session.add_all( + [ + User( + uuid=creator_uuid, + user='creator@example.test', + normalized_email='creator@example.test', + password='hash', + account_type='local', + ), + User( + uuid=promoted_uuid, + user='promoted@example.test', + normalized_email='promoted@example.test', + password='hash', + account_type='local', + ), + Workspace( + uuid=workspace_uuid, + instance_uuid='instance-test', + name='Workspace', + slug='workspace', + type='team', + status='active', + source='local', + created_by_account_uuid=creator_uuid, + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000100', + workspace_uuid=workspace_uuid, + account_uuid=creator_uuid, + role='owner', + status='active', + ), + WorkspaceMembership( + uuid='00000000-0000-4000-8000-000000000200', + workspace_uuid=workspace_uuid, + account_uuid=promoted_uuid, + role='owner', + status='active', + ), + ] + ) + await session.commit() + + await run_alembic_stamp(engine, '0018_merge_launch_replay') + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as connection: + roles = dict( + ( + await connection.execute( + sa.text( + 'SELECT account_uuid, role FROM workspace_memberships ' + 'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid' + ), + {'workspace_uuid': workspace_uuid}, + ) + ).all() + ) + assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'} + indexes = await connection.run_sync( + lambda sync_connection: { + index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships') + } + ) + assert 'uq_workspace_memberships_one_active_owner' in indexes + + with pytest.raises(sa.exc.IntegrityError): + async with engine.begin() as connection: + await connection.execute( + sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"), + {'account_uuid': promoted_uuid}, + ) + finally: + await engine.dispose() diff --git a/tests/unit_tests/api/http/test_authz.py b/tests/unit_tests/api/http/test_authz.py index 705891a16..620700bc5 100644 --- a/tests/unit_tests/api/http/test_authz.py +++ b/tests/unit_tests/api/http/test_authz.py @@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission(): assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission) -def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing(): +def test_admin_cannot_delete_workspace_or_link_billing(): ctx = _context(authz.WorkspaceRole.ADMIN) - assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER) assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE) assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE) assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE) diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py index 6f4b0a275..5d1de001f 100644 --- a/tests/unit_tests/cloud/test_model_catalog.py +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -73,11 +73,13 @@ def _snapshot( 'workspace_uuid': WORKSPACE_A, 'owner_account_uuid': OWNER_A, 'api_key': key_a, + 'credits': 25000, }, { 'workspace_uuid': WORKSPACE_B, 'owner_account_uuid': OWNER_B, 'api_key': 'owner-b-key', + 'credits': 5000, }, ], } @@ -160,6 +162,8 @@ async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_ first = await service.sync_once() assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0} assert reload_counter.calls == 1 + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) == 5000 async with engine.connect() as connection: providers = ( @@ -306,6 +310,8 @@ async def test_partial_workspace_failure_reloads_already_committed_changes() -> with pytest.raises(RuntimeError, match='second Workspace failed'): await service.sync_once() + assert service.get_workspace_credits(WORKSPACE_A) == 25000 + assert service.get_workspace_credits(WORKSPACE_B) is None assert reload_counter.calls == 1 diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index a9a79c42b..77974f4ff 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context): second_membership, ) - promoted = await service.update_member_role( - workspace.uuid, - second.uuid, - 'owner', - owner_membership, - ) - assert promoted.role == 'owner' - demoted = await service.update_member_role( - workspace.uuid, - owner_membership.account_uuid, - 'admin', - owner_membership, - ) - assert demoted.role == 'admin' + with pytest.raises(MembershipPermissionError, match='cannot be transferred'): + await service.update_member_role( + workspace.uuid, + second.uuid, + 'owner', + owner_membership, + ) + + with pytest.raises(LastOwnerError): + await service.update_member_role( + workspace.uuid, + owner_membership.account_uuid, + 'admin', + owner_membership, + ) async def test_workspace_selector_requires_membership(collaboration_context): diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx index b771d77bc..b4dd71866 100644 --- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -218,20 +218,22 @@ export default function ProviderCard({ {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} - + {isWorkspaceOwner && ( + + )}
)} {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && ( diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index ebe184040..2469c5194 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({ const canInvite = permissions.has('member.invite'); const canUpdateMembers = permissions.has('member.update_role'); const canRemoveMembers = permissions.has('member.remove'); - const canTransferOwner = permissions.has('owner.transfer'); const cloudPortalURL = workspaceInfo ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` : ''; @@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({ {t(`workspace.roles.${role}`)} ))} - {canTransferOwner && ( - - {t('workspace.transferOwnership')} - - )} )} diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 23e2c0a8f..98ad9f25a 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -169,7 +169,6 @@ export function makeWorkspaceEntry( 'member.remove', 'member.update_role', 'member.view', - 'owner.transfer', 'provider_secret.manage', 'resource.manage', 'resource.view', diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs index 963b78ba8..e49cf43cb 100644 --- a/web/tests/unit/oss-account-space-billing.test.mjs +++ b/web/tests/unit/oss-account-space-billing.test.mjs @@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly', assert.match(source, /ownerSpaceBound/); assert.match(source, /models\.ownerMustBindSpace/); assert.match(source, /models\.usesOwnerSpaceBilling/); + assert.match(source, /isWorkspaceOwner && \(\s*