diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..2162f94 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,29 @@ +COMPOSE_PROJECT_NAME=opencli-admin +DOCKER_REGISTRY=ghcr.io/ +DOCKER_IMAGE_NAMESPACE=2233admin +IMAGE_TAG=0.4.0 + +FRONTEND_PORT=3010 +API_PORT=8031 +PUBLIC_URL=http://localhost:8031 + +# Required. The installers generate all four values automatically. +API_AUTH_TOKEN= +BOOTSTRAP_ADMIN_TOKEN= +SECRET_KEY= +CREDENTIAL_ENCRYPTION_KEY= + +DATABASE_URL=sqlite+aiosqlite:////data/opencli_admin.db +TASK_EXECUTOR=local +COLLECTION_MODE=local +DEBUG=false + +# Interactive browser is available at http://localhost:6080. +OPENCLI_CDP_ENDPOINT=http://agent-1:19222 +NOVNC_PORT=6080 +NOVNC_BASE_PORT=6080 + +# Remote-agent defaults. +AGENT_MODE=bridge +OPENCLI_DAEMON_PORT=19825 +CHROME_SUFFIX= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aaea72..543960e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,43 +9,10 @@ on: pull_request: workflow_dispatch: -jobs: - frontend: - runs-on: ubuntu-latest - name: Frontend (Next.js) - defaults: - run: - working-directory: frontend - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 11 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm exec tsc --noEmit - - # TODO: add pnpm test once a frontend test suite exists - - - name: Lint - run: pnpm run lint - - - name: Build - run: pnpm run build +permissions: + contents: read +jobs: extension: runs-on: ubuntu-latest name: Browser Extension @@ -99,8 +66,8 @@ jobs: with: python-version: "3.13" - - name: Install workflow contract dependency - run: python -m pip install "pydantic>=2.10.0" + - name: Install backend compiler dependencies + run: python -m pip install -e .. - name: Install dependencies run: | @@ -126,6 +93,45 @@ jobs: - name: Browser smoke test run: pnpm test:smoke + release-contract: + runs-on: ubuntu-latest + name: Public Install Smoke + env: + API_AUTH_TOKEN: ci-release-token + BOOTSTRAP_ADMIN_TOKEN: ci-bootstrap-admin-token + SECRET_KEY: ci-release-secret + CREDENTIAL_ENCRYPTION_KEY: MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA= + COMPOSE_PROJECT_NAME: opencli-admin-ci + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate Compose + run: docker compose --env-file .env.docker.example -f docker-compose.yml -f docker-compose.build.yml config --quiet + + - name: Build and start public stack + run: docker compose --env-file .env.docker.example -f docker-compose.yml -f docker-compose.build.yml up -d --build --wait api frontend agent-1 + + - name: Verify public endpoints + run: | + curl --fail --silent --show-error http://localhost:3010/login >/dev/null + curl --fail --silent --show-error http://localhost:8031/health >/dev/null + curl --fail --silent --show-error \ + -H "Authorization: Bearer ci-bootstrap-admin-token" \ + -H "X-API-Token: ci-release-token" \ + http://localhost:8031/api/v1/auth/me | + python -c 'import json,sys; assert json.load(sys.stdin)["data"]["subject"] == "bootstrap-admin"' + + - name: Show logs on failure + if: failure() + run: docker compose --env-file .env.docker.example -f docker-compose.yml -f docker-compose.build.yml logs --tail=200 api frontend agent-1 + + - name: Stop public stack + if: always() + run: docker compose --env-file .env.docker.example -f docker-compose.yml -f docker-compose.build.yml down -v + backend: runs-on: ubuntu-latest name: Backend Quality diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bee0845 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,129 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + images: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - name: API image + image: opencli-admin-api + context: . + file: Dockerfile + build_args: | + IMAGE_TAG=__VERSION__ + suffix: "" + - name: Frontend image + image: opencli-admin-frontend + context: ./frontend + file: ./frontend/Dockerfile + build_args: | + BACKEND_URL=http://api:8000 + suffix: "" + - name: Agent image + image: opencli-admin-agent + context: . + file: ./agent/Dockerfile + build_args: "" + suffix: "" + - name: Interactive Chrome image + image: opencli-admin-chrome + context: . + file: ./chrome/Dockerfile + build_args: "" + suffix: "" + - name: Agent Chrome image + image: opencli-admin-agent + context: . + file: ./agent/Dockerfile + suffix: "-chrome" + build_args: | + INSTALL_CHROME=true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Resolve release version + id: version + shell: bash + run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Resolve build arguments + id: build_args + shell: bash + env: + MATRIX_BUILD_ARGS: ${{ matrix.build_args }} + RELEASE_VERSION: ${{ steps.version.outputs.value }} + run: | + { + echo "value<> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.file }} + platforms: linux/amd64,linux/arm64 + push: true + build-args: ${{ steps.build_args.outputs.value }} + tags: | + ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:${{ steps.version.outputs.value }}${{ matrix.suffix }} + ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:latest${{ matrix.suffix }} + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} + provenance: mode=max + sbom: true + + github-release: + name: GitHub Release + runs-on: ubuntu-latest + needs: images + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + docker-compose.yml + docker-compose.build.yml + .env.docker.example + scripts/install.sh + scripts/install.ps1 diff --git a/Dockerfile b/Dockerfile index ff0045b..f2f639c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,6 @@ FROM ${REGISTRY}python:3.13-slim AS builder WORKDIR /app -# Switch to Aliyun apt mirror for faster downloads in China -RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - # Install build deps RUN apt-get update && apt-get install -y --no-install-recommends \ gcc libpq-dev \ @@ -15,8 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Python deps into a prefix so we can copy them cleanly COPY pyproject.toml . -RUN pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ && \ - pip install --prefix=/install . -i https://mirrors.aliyun.com/pypi/simple/ +RUN pip install --upgrade pip \ + && pip install --prefix=/install . # ── Stage 2: runtime ────────────────────────────────────────────────────────── ARG REGISTRY= @@ -24,10 +20,6 @@ FROM ${REGISTRY}python:3.13-slim AS runtime WORKDIR /app -# Switch to Aliyun apt mirror for faster downloads in China -RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - # Runtime system deps (psycopg2 needs libpq, opencli needs Node.js 22+) RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 curl ca-certificates git \ @@ -44,23 +36,13 @@ RUN npm install -g @jackwener/opencli@${OPENCLI_VERSION} \ && rm /tmp/patch-opencli.js \ && rm -rf /root/.npm -ARG OHMYOPENCLI_REPO=https://github.com/2233admin/OhMyOpenCLI.git -ARG OHMYOPENCLI_COMMIT=73cc60c83586ef2c95469b3b70d6cfc80fa5bc53 -ARG OFFICIAL_SITE_CAPABILITY_COMMIT=73cc60c83586ef2c95469b3b70d6cfc80fa5bc53 -RUN git clone ${OHMYOPENCLI_REPO} /opt/ohmyopencli \ - && cd /opt/ohmyopencli \ - && git checkout --detach ${OHMYOPENCLI_COMMIT} \ - && git merge-base --is-ancestor ${OFFICIAL_SITE_CAPABILITY_COMMIT} HEAD \ - && npm ci \ - && test "$(git rev-parse HEAD)" = "${OHMYOPENCLI_COMMIT}" - # Copy installed packages from builder COPY --from=builder /install /usr/local # Copy application source COPY backend/ ./backend/ COPY scripts/patch-opencli.js ./scripts/patch-opencli.js -COPY scripts/verify_managed_opencli_runtime.py ./scripts/verify_managed_opencli_runtime.py +COPY scripts/install-agent.sh ./scripts/install-agent.sh COPY alembic.ini . # Entrypoint handles migrations @@ -70,16 +52,12 @@ RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh # Non-root user for security; pre-create /data so the SQLite volume is writable RUN useradd -m -u 1000 appuser && \ mkdir -p /data && \ - chown -R appuser:appuser /app /data /opt/ohmyopencli \ - && cd /opt/ohmyopencli \ - && HOME=/home/appuser npm run bootstrap \ - && chown -R appuser:appuser /home/appuser/.opencli /opt/ohmyopencli + chown -R appuser:appuser /app /data USER appuser ENV PYTHONPATH=/app \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - OHMYOPENCLI_ROOT=/opt/ohmyopencli + PYTHONUNBUFFERED=1 # Bake the image tag so the system config API can serve it to clients. ARG IMAGE_TAG=latest ENV IMAGE_TAG=${IMAGE_TAG} diff --git a/README.md b/README.md index 3cd72a4..e861013 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # OpenCLI Admin -[![Docker](https://img.shields.io/badge/Docker%20Hub-0.3.6-blue?logo=docker)](https://hub.docker.com/u/2233admin) +[![GitHub Release](https://img.shields.io/github/v/release/2233admin/opencli-admin)](https://github.com/2233admin/opencli-admin/releases) +[![CI](https://github.com/2233admin/opencli-admin/actions/workflows/ci.yml/badge.svg)](https://github.com/2233admin/opencli-admin/actions/workflows/ci.yml) **现代化的数据采集系统** — 可视化管理多渠道数据采集,接入 [opencli](https://github.com/jackwener/opencli) 驱动国内外主流平台,支持 AI 处理、多节点分布式调度与实时通知推送。 @@ -51,7 +52,7 @@ Mac Mini A(主控) Mac Mini B Mac Mini C ┌──────────────────┐ WS ┌─────────────────┐ WS ┌─────────────────┐ │ opencli-admin │◄──────────│ opencli-agent │ │ opencli-agent │ -│ 管理界面 :8030 │◄──────────│ 登录:B站/小红书 │ │ 登录:Twitter/X │ +│ 管理界面 :3010 │◄──────────│ 登录:B站/小红书 │ │ 登录:Twitter/X │ │ API :8031 │ │ 采集国内平台 │ │ 采集海外平台 │ └──────────────────┘ └─────────────────┘ └─────────────────┘ ``` @@ -81,10 +82,10 @@ B / C 两台用 Shell 脚本一键安装 Agent,WS 反向通道注册,穿透 ### 单机全能采集(最简部署) ```bash -docker compose up -d # 启动中心 + agent-1 +curl -fsSL https://raw.githubusercontent.com/2233admin/opencli-admin/v0.4.0/scripts/install.sh | sh ``` -在「节点管理」动态添加 agent-2、agent-3,各实例独立 Chrome Profile,支持同一平台多账号并行。 +安装完成后打开 `http://localhost:3010`,再打开 `http://localhost:6080` 进入内置浏览器扫码或登录平台账号。 --- @@ -149,7 +150,7 @@ ACCEPTANCE: PASS ## 快速开始 -### 方式零:前后端本地开发(推荐) +### 方式零:前后端本地开发 新前端在仓库内 `frontend/` 下开发和构建: @@ -182,10 +183,9 @@ cp .env.example .env | 服务 | 地址 | |------|------| -| 管理界面 | http://localhost:8030 | | API 文档 | http://localhost:8031/docs | -脚本自动创建 venv、安装依赖、初始化数据库、启动 Chrome CDP、后端热重载、前端 HMR。 +`start.sh` 启动后端与 Chrome;前端另开终端运行 `npm run dev:frontend`。 ```bash ./start.sh --no-chrome # 跳过 Chrome(RSS/API 渠道不需要) @@ -200,38 +200,40 @@ cp .env.example .env **前置要求**:Docker & Docker Compose -> **Agent 镜像两个变体**: -> - `opencli-admin-agent:0.3.6` — 默认,约 100 MB,通过 `host.docker.internal` 连接宿主机 Chrome -> - `opencli-admin-agent:0.3.6-chrome` — 约 450 MB,内置 Chromium,完全自包含 - -**启动宿主机 Chrome**(若使用默认无 Chrome 变体): +Linux / macOS: ```bash -# macOS -/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ - --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 \ - --no-first-run --no-default-browser-check & +curl -fsSL https://raw.githubusercontent.com/2233admin/opencli-admin/v0.4.0/scripts/install.sh | sh +``` + +Windows PowerShell: +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-admin/v0.4.0/scripts/install.ps1 -OutFile install.ps1 +.\install.ps1 ``` +安装器会生成安全密钥、拉取公开 GHCR 镜像并等待健康检查通过。终端会打印首次登录用的 `BOOTSTRAP_ADMIN_TOKEN` 和边缘节点用的 `API_AUTH_TOKEN`。 + +从源码构建: + ```bash -cp .env.example .env -docker compose up -d +cp .env.docker.example .env +# 填写 API_AUTH_TOKEN、BOOTSTRAP_ADMIN_TOKEN、SECRET_KEY、CREDENTIAL_ENCRYPTION_KEY +docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d ``` | 服务 | 地址 | |------|------| -| 管理界面 | http://localhost:8030 | +| 管理界面 | http://localhost:3010 | | API 文档 | http://localhost:8031/docs | -| Agent noVNC | http://localhost:3010 | +| 内置浏览器 / 扫码登录 | http://localhost:6080 | -默认 Compose 会从仓库内构建前端。后端和 agent 默认仍可使用已发布镜像;如需全部从源码构建: +**停止**:`docker compose down` -```bash -docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d -``` +公开镜像只包含 OpenCLI 核心能力,不会隐式下载组织私有适配包;需要额外适配包时必须显式提供经过审计的仓库。 -**停止**:`docker compose down` +首次打开管理界面时,在“管理员身份令牌”中输入 `BOOTSTRAP_ADMIN_TOKEN`;“Fleet API 令牌”中输入 `API_AUTH_TOKEN`,用于调用受保护的采集与节点接口。 --- @@ -242,8 +244,8 @@ opencli 渠道依赖浏览器登录态,首次使用需手动登录各平台账 | 启动方式 | 操作 | |----------|------| | 原生 Shell | 脚本启动后在 Chrome 窗口中登录。登录态保存在 `~/.opencli-admin/chrome-profile/` | -| Docker 单实例 | 打开 http://localhost:3010(noVNC)在界面中登录 | -| Docker 多实例 | 各实例独立 Profile,分别登录。agent-2 → :3011,agent-3 → :3012 | +| Docker 单实例 | 打开 http://localhost:6080,在内置 Chromium 中扫码或登录 | +| 远程服务器 | 先执行 `ssh -L 6080:127.0.0.1:6080 user@server`,再访问本机 `http://localhost:6080` | > 需要登录:小红书、Bilibili、知乎、微博、Twitter/X、LinkedIn、YouTube。Hacker News、BBC、RSS 等公开内容无需登录。 @@ -268,31 +270,36 @@ opencli 渠道依赖浏览器登录态,首次使用需手动登录各平台账 # WS 模式(NAT / 跨网) docker run -d --name opencli-agent --restart unless-stopped \ --add-host=host.docker.internal:host-gateway \ - -e CENTRAL_API_URL=http://:8030 \ + -e CENTRAL_API_URL=http://:8031 \ -e AGENT_REGISTER=ws -e AGENT_MODE=bridge \ + -e AGENT_API_TOKEN= \ -p 19823:19823 \ - 2233admin/opencli-admin-agent:0.3.6 + ghcr.io/2233admin/opencli-admin-agent:0.4.0 # HTTP 模式(局域网) docker run -d --name opencli-agent --restart unless-stopped \ --add-host=host.docker.internal:host-gateway \ - -e CENTRAL_API_URL=http://:8030 \ + -e CENTRAL_API_URL=http://:8031 \ -e AGENT_REGISTER=http -e AGENT_MODE=bridge \ + -e AGENT_API_TOKEN= \ -p 19823:19823 \ - 2233admin/opencli-admin-agent:0.3.6 + ghcr.io/2233admin/opencli-admin-agent:0.4.0 ``` **一键脚本安装** ```bash # Docker 安装(无 Chrome 镜像) -curl -fsSL http://
:8030/api/v1/nodes/install/agent.sh | bash +curl -fsSL -H "Authorization: Bearer $API_AUTH_TOKEN" \ + http://
:8031/api/v1/nodes/install/agent.sh | bash # Docker 安装,含 Chrome(无需宿主机 Chrome) -curl -fsSL http://
:8030/api/v1/nodes/install/agent.sh | bash -s -- docker --install-chrome +curl -fsSL -H "Authorization: Bearer $API_AUTH_TOKEN" \ + http://
:8031/api/v1/nodes/install/agent.sh | bash -s -- docker --install-chrome # Shell 安装(无 Docker 环境) -curl -fsSL http://
:8030/api/v1/nodes/install/agent.sh | \ +curl -fsSL -H "Authorization: Bearer $API_AUTH_TOKEN" \ + http://
:8031/api/v1/nodes/install/agent.sh | \ AGENT_REGISTER=ws AGENT_MODE=bridge bash -s -- python ``` @@ -469,48 +476,13 @@ AI 处理(可选)— Claude · OpenAI · DeepSeek · Kimi · GLM · Ollama ## 发布镜像 -构建并推送 amd64 + arm64 多平台镜像(需要 `multiarch` buildx builder): - -```bash -TAG=0.3.6 - -# API(将 IMAGE_TAG 烘焙进镜像,供安装脚本动态注入版本号) -docker buildx build --builder multiarch \ - --platform linux/amd64,linux/arm64 \ - --build-arg IMAGE_TAG=${TAG} \ - -t 2233admin/opencli-admin-api:${TAG} --push . - -# Agent 基础版(~100 MB,通过宿主机 Chrome 连接) -docker buildx build --builder multiarch \ - --platform linux/amd64,linux/arm64 \ - -f agent/Dockerfile \ - -t 2233admin/opencli-admin-agent:${TAG} --push . - -# Agent 内置 Chrome 版(~450 MB,完全自包含) -docker buildx build --builder multiarch \ - --platform linux/amd64,linux/arm64 \ - -f agent/Dockerfile \ - --build-arg INSTALL_CHROME=true \ - -t 2233admin/opencli-admin-agent:${TAG}-chrome --push . -``` - -如需并行构建所有镜像: - -```bash -TAG=0.3.6 -docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \ - --build-arg IMAGE_TAG=${TAG} \ - -t 2233admin/opencli-admin-api:${TAG} --push . > /tmp/build-api.log 2>&1 & -docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \ - -f agent/Dockerfile \ - -t 2233admin/opencli-admin-agent:${TAG} --push . > /tmp/build-agent.log 2>&1 & -docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \ - -f agent/Dockerfile --build-arg INSTALL_CHROME=true \ - -t 2233admin/opencli-admin-agent:${TAG}-chrome --push . > /tmp/build-agent-chrome.log 2>&1 & -wait && echo "done" -``` +推送 `v*` 标签后,GitHub Actions 自动构建 amd64 / arm64 镜像并创建 GitHub Release: -> 首次使用需创建 builder:`docker buildx create --name multiarch --use` +- `ghcr.io/2233admin/opencli-admin-api:0.4.0` +- `ghcr.io/2233admin/opencli-admin-frontend:0.4.0` +- `ghcr.io/2233admin/opencli-admin-chrome:0.4.0` +- `ghcr.io/2233admin/opencli-admin-agent:0.4.0` +- `ghcr.io/2233admin/opencli-admin-agent:0.4.0-chrome` ## License diff --git a/agent/Dockerfile b/agent/Dockerfile index 6a9ca27..e842b6b 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -1,14 +1,10 @@ ARG REGISTRY= FROM ${REGISTRY}python:3.13-slim -# Switch to Aliyun apt mirror for faster downloads in China -RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - # Base system deps (always installed) RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates git procps \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* @@ -32,19 +28,6 @@ RUN npm install -g @jackwener/opencli@${OPENCLI_VERSION} \ && rm /tmp/patch-opencli.js \ && rm -rf /root/.npm -# Project-owned capability package. Keep the repository and capability-source -# identities separate: the latter is the behavior change, while the former is -# the exact checkout certified by this image. -ARG OHMYOPENCLI_REPO=https://github.com/2233admin/OhMyOpenCLI.git -ARG OHMYOPENCLI_COMMIT=73cc60c83586ef2c95469b3b70d6cfc80fa5bc53 -ARG OFFICIAL_SITE_CAPABILITY_COMMIT=73cc60c83586ef2c95469b3b70d6cfc80fa5bc53 -RUN git clone ${OHMYOPENCLI_REPO} /opt/ohmyopencli \ - && cd /opt/ohmyopencli \ - && git checkout --detach ${OHMYOPENCLI_COMMIT} \ - && git merge-base --is-ancestor ${OFFICIAL_SITE_CAPABILITY_COMMIT} HEAD \ - && npm ci \ - && test "$(git rev-parse HEAD)" = "${OHMYOPENCLI_COMMIT}" - # Python deps for agent_server only — intentionally minimal RUN pip install --no-cache-dir \ fastapi \ @@ -65,7 +48,6 @@ WORKDIR /app COPY backend/agent_server.py ./backend/agent_server.py COPY backend/agent_runtimes/ ./backend/agent_runtimes/ COPY backend/miniflow/ ./backend/miniflow/ -COPY scripts/verify_managed_opencli_runtime.py ./scripts/verify_managed_opencli_runtime.py RUN touch ./backend/__init__.py COPY agent/entrypoint.sh /entrypoint.sh @@ -73,10 +55,7 @@ RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh RUN useradd -m -u 1000 agent \ && mkdir -p /home/agent/.config/chromium \ - && chown -R agent:agent /home/agent /app /opt/ohmyopencli \ - && cd /opt/ohmyopencli \ - && HOME=/home/agent npm run bootstrap \ - && chown -R agent:agent /home/agent/.opencli /opt/ohmyopencli + && chown -R agent:agent /home/agent /app ARG INSTALL_CHROME=false ENV PYTHONPATH=/app \ @@ -87,8 +66,7 @@ ENV PYTHONPATH=/app \ # Baked at build time so agent_server knows whether Chrome is bundled. # true → connect to container-local Chrome (localhost) # false → connect to host Chrome (host.docker.internal) - AGENT_HAS_CHROME=${INSTALL_CHROME} \ - OHMYOPENCLI_ROOT=/opt/ohmyopencli + AGENT_HAS_CHROME=${INSTALL_CHROME} USER agent diff --git a/backend/agent_server.py b/backend/agent_server.py index 8533a2a..d8d886b 100644 --- a/backend/agent_server.py +++ b/backend/agent_server.py @@ -546,7 +546,7 @@ async def lifespan(app: FastAPI): pass -app = FastAPI(title="OpenCLI Agent Server", version="0.1.0", lifespan=lifespan) +app = FastAPI(title="OpenCLI Agent Server", version="0.4.0", lifespan=lifespan) class CollectRequest(BaseModel): diff --git a/backend/api/v1/browsers.py b/backend/api/v1/browsers.py index 849f19a..29d939e 100644 --- a/backend/api/v1/browsers.py +++ b/backend/api/v1/browsers.py @@ -160,9 +160,9 @@ async def add_chrome_instance( pool = get_pool() project = _project_name() - novnc_base = int(os.environ.get("NOVNC_BASE_PORT", 3010)) + novnc_base = int(os.environ.get("NOVNC_BASE_PORT", 6080)) network = f"{project}_default" - image = f"{project}-chrome" + image = os.environ.get("CHROME_IMAGE", f"{project}-chrome") client = _docker_client() created: list[dict] = [] @@ -189,7 +189,7 @@ async def add_chrome_instance( name=name, network=network, labels={"agent.pool.extra": "true", "agent.pool.index": str(N)}, - ports={"6080/tcp": novnc_port}, + ports={"6080/tcp": ("127.0.0.1", novnc_port)}, volumes={volume: {"bind": "/home/chrome/.config/chromium", "mode": "rw"}}, restart_policy={"Name": "unless-stopped"}, ) diff --git a/backend/api/v1/nodes.py b/backend/api/v1/nodes.py index fb71483..b225d86 100644 --- a/backend/api/v1/nodes.py +++ b/backend/api/v1/nodes.py @@ -687,7 +687,7 @@ def _install_script_template( -e AGENT_LABEL="$AGENT_LABEL" -e AGENT_MODE="cdp" \\ -e AGENT_API_TOKEN="$AGENT_API_TOKEN" \\ $PROXY_ARGS -p "${{AGENT_PORT}}:${{AGENT_PORT}}" \\ - "xjh1994/opencli-admin-agent:${{IMAGE_TAG}}" + "ghcr.io/2233admin/opencli-admin-agent:${{IMAGE_TAG}}" info "Agent container started!" }} diff --git a/backend/config.py b/backend/config.py index 4570619..4cd4220 100644 --- a/backend/config.py +++ b/backend/config.py @@ -122,7 +122,7 @@ def cli_allowed_binaries(self) -> list[str]: agent_pool_endpoints: str = "" # noVNC base port for the first agent instance (agent-1). Additional # instances use base+1, base+2, … Matches docker-compose NOVNC_PORT. - novnc_base_port: int = 3010 + novnc_base_port: int = 6080 @property def cdp_endpoints(self) -> list[str]: diff --git a/backend/main.py b/backend/main.py index a9a7a3a..3e45dfe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -222,7 +222,7 @@ def create_app() -> FastAPI: app = FastAPI( title="OpenCLI Admin", description="Multi-channel data collection management system", - version="0.1.0", + version="0.4.0", docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", diff --git a/backend/mcp_server.py b/backend/mcp_server.py index fe7e8b3..0422d2a 100644 --- a/backend/mcp_server.py +++ b/backend/mcp_server.py @@ -88,7 +88,7 @@ def _transport_security() -> TransportSecuritySettings: mcp = MCPServer( "opencli-admin", - version="0.1.0", + version="0.4.0", instructions=( "Use project tools for immutable published workflow runs and their durable traces. " "Use source tools for collection administration." diff --git a/backend/migrations/versions/j7k8l9m0n1o2_link_studio_runs_to_published_versions.py b/backend/migrations/versions/j7k8l9m0n1o2_link_studio_runs_to_published_versions.py index 8ebd9aa..dff18a8 100644 --- a/backend/migrations/versions/j7k8l9m0n1o2_link_studio_runs_to_published_versions.py +++ b/backend/migrations/versions/j7k8l9m0n1o2_link_studio_runs_to_published_versions.py @@ -6,7 +6,7 @@ """ import sqlalchemy as sa -from alembic import op +from alembic import context, op revision = "j7k8l9m0n1o2" down_revision = "i6j7k8l9m0n1" @@ -15,6 +15,12 @@ def upgrade() -> None: + if ( + not context.is_offline_mode() + and "workflow_runs" not in sa.inspect(op.get_bind()).get_table_names() + ): + return + with op.batch_alter_table("workflow_runs") as batch: batch.add_column( sa.Column("studio_workflow_version_id", sa.String(length=36), nullable=True) @@ -34,6 +40,12 @@ def upgrade() -> None: def downgrade() -> None: + if ( + not context.is_offline_mode() + and "workflow_runs" not in sa.inspect(op.get_bind()).get_table_names() + ): + return + with op.batch_alter_table("workflow_runs") as batch: batch.drop_index("ix_workflow_runs_studio_workflow_version_id") batch.drop_constraint( diff --git a/backend/migrations/versions/k8l9m0n1o2p3_add_operations_agent_run_contract_payloads.py b/backend/migrations/versions/k8l9m0n1o2p3_add_operations_agent_run_contract_payloads.py index 2ee1afb..7a5c940 100644 --- a/backend/migrations/versions/k8l9m0n1o2p3_add_operations_agent_run_contract_payloads.py +++ b/backend/migrations/versions/k8l9m0n1o2p3_add_operations_agent_run_contract_payloads.py @@ -6,7 +6,7 @@ """ import sqlalchemy as sa -from alembic import op +from alembic import context, op revision = "k8l9m0n1o2p3" down_revision = "j7k8l9m0n1o2" @@ -15,6 +15,12 @@ def upgrade() -> None: + if ( + not context.is_offline_mode() + and "operations_agent_runs" not in sa.inspect(op.get_bind()).get_table_names() + ): + return + with op.batch_alter_table("operations_agent_runs") as batch: batch.add_column( sa.Column( @@ -37,6 +43,12 @@ def upgrade() -> None: def downgrade() -> None: + if ( + not context.is_offline_mode() + and "operations_agent_runs" not in sa.inspect(op.get_bind()).get_table_names() + ): + return + with op.batch_alter_table("operations_agent_runs") as batch: batch.drop_column("error_message") batch.drop_column("output_payload") diff --git a/chrome/Dockerfile b/chrome/Dockerfile index 517621e..72ea0ec 100644 --- a/chrome/Dockerfile +++ b/chrome/Dockerfile @@ -1,10 +1,6 @@ ARG REGISTRY= FROM ${REGISTRY}debian:bookworm-slim -# Switch to Aliyun apt mirror for faster downloads in China -RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - # OPENCLI_VERSION: pin the opencli release; patch-opencli.js adds DAEMON_HOST/LISTEN env hooks ARG OPENCLI_VERSION=1.8.5 COPY scripts/patch-opencli.js /tmp/patch-opencli.js @@ -39,7 +35,7 @@ COPY chrome/extension-src/manifest.json /home/chrome/extension/manifest.json COPY chrome/extension-src/icons/ /home/chrome/extension/icons/ COPY chrome/extension-src/dist/background.js /home/chrome/extension/dist/background.js -EXPOSE 9222 6080 +EXPOSE 6080 19222 19825 USER chrome # Pre-create profile directory as chrome user so Docker initialises new diff --git a/docker-compose.build.yml b/docker-compose.build.yml index e85b69b..be7c083 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,5 +1,4 @@ -# Local build override: builds backend and agent images from source. -# Frontend lives outside this clone; use C:\c\Users\Administrator\projects\open-cli-admin. +# Local build override: builds the release images from this checkout. # # Usage: # docker compose -f docker-compose.yml -f docker-compose.build.yml up --build @@ -22,7 +21,19 @@ x-agent-build: &agent-build # Set INSTALL_CHROME=true to embed Chromium + Xvfb (~1.2 GB), fully self-contained. INSTALL_CHROME: ${INSTALL_CHROME:-false} +x-chrome-build: &chrome-build + build: + context: . + dockerfile: chrome/Dockerfile + services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + BACKEND_URL: http://api:8000 + api: <<: *backend-build @@ -33,7 +44,7 @@ services: <<: *backend-build agent-1: - <<: *agent-build + <<: *chrome-build # Remote edge agent: same image, same default (no embedded Chrome). agent: diff --git a/docker-compose.yml b/docker-compose.yml index f5abe15..84ef3f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,11 @@ # Shared backend config (DRY) x-backend-common: &backend-common - image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-api:${IMAGE_TAG:-0.3.6} + image: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-api:${IMAGE_TAG:-0.4.0} env_file: - path: .env required: false volumes: - db_data:/data - - ./backend:/app/backend restart: unless-stopped services: @@ -155,23 +154,26 @@ services: REDIS_URL: redis://redis:6379/0 DEBUG: ${DEBUG:-false} SECRET_KEY: ${SECRET_KEY:-change-me-in-production} - API_AUTH_TOKEN: ${API_AUTH_TOKEN:-} + API_AUTH_TOKEN: ${API_AUTH_TOKEN:?Set API_AUTH_TOKEN in .env or run scripts/install.sh} + BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:?Set BOOTSTRAP_ADMIN_TOKEN in .env or run scripts/install.sh} OPENCLI_MCP_ALLOWED_HOSTS: ${OPENCLI_MCP_ALLOWED_HOSTS:-} OPENCLI_MCP_ALLOWED_ORIGINS: ${OPENCLI_MCP_ALLOWED_ORIGINS:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - # Built-in sidecar agent URL (Chrome + agent_server in one container). + # Built-in interactive browser endpoint. # For COLLECTION_MODE=local this is pre-loaded into the browser pool. - OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://agent-1:19823} + OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://agent-1:19222} # Multi-instance pool: overrides OPENCLI_CDP_ENDPOINT when set AGENT_POOL_ENDPOINTS: ${AGENT_POOL_ENDPOINTS:-} - # Collection mode: "local" (default, built-in agent-1) or "agent" (distributed edge nodes) + # Collection mode: "local" (default browser) or "agent" (distributed edge nodes) COLLECTION_MODE: ${COLLECTION_MODE:-local} # Public-facing URL for install scripts and agent registration links. # Set to the URL remote agents will use to reach this center (e.g. http://192.168.1.1:8031). PUBLIC_URL: ${PUBLIC_URL:-} # Passed to the agent pool manager so it can name containers/networks correctly COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-opencli-admin} + CHROME_IMAGE: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-chrome:${IMAGE_TAG:-0.4.0} + NOVNC_BASE_PORT: ${NOVNC_BASE_PORT:-6080} # NAS + III: cron 由 III schedule-bootstrap 驱动,API 不再跑内置 scheduler COLLECTION_ORCHESTRATOR: ${COLLECTION_ORCHESTRATOR:-admin} ODP_INGEST_URL: ${ODP_INGEST_URL:-} @@ -184,11 +186,8 @@ services: IMAGE_ASSET_STORAGE_PATH: /data/image-studio/assets volumes: - db_data:/data - - ./backend:/app/backend # Docker socket: lets the API start/stop agent pool containers - /var/run/docker.sock:/var/run/docker.sock - # .env mount: lets the API persist AGENT_POOL_ENDPOINTS after pool changes - - ./.env:/app/.env:rw ports: - "${API_PORT:-8031}:8000" networks: @@ -202,6 +201,31 @@ services: timeout: 5s retries: 5 start_period: 20s + depends_on: + agent-1: + condition: service_healthy + + # ── Next.js operator console ───────────────────────────────────────────── + frontend: + image: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-frontend:${IMAGE_TAG:-0.4.0} + environment: + BACKEND_URL: http://api:8000 + ports: + - "${FRONTEND_PORT:-3010}:3010" + depends_on: + api: + condition: service_healthy + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:3010/login').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + interval: 10s + timeout: 5s + retries: 6 + start_period: 20s + restart: unless-stopped # ── Celery Worker (only needed for TASK_EXECUTOR=celery) ───────────────── worker: @@ -218,7 +242,7 @@ services: API_AUTH_TOKEN: ${API_AUTH_TOKEN:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://agent-1:19823} + OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://agent-1:19222} AGENT_POOL_ENDPOINTS: ${AGENT_POOL_ENDPOINTS:-} KATS_RUNTIME_URL: ${KATS_RUNTIME_URL:-http://kats-runtime:8096} INVOKEAI_ENABLED: ${INVOKEAI_ENABLED:-false} @@ -241,7 +265,9 @@ services: # upgrades require the upstream/API/schema/GPU gates in ADR-0026. invokeai: profiles: ["image-studio"] - image: ${INVOKEAI_ATTESTED_IMAGE:?Set an image built from the approved InvokeAI commit as repository@sha256:digest} + # Deliberately invalid fallback: default Compose remains parseable, while + # starting this profile still requires an explicitly attested image. + image: ${INVOKEAI_ATTESTED_IMAGE:-invalid.invalid/opencli/invokeai-attested-image-required@sha256:0000000000000000000000000000000000000000000000000000000000000000} read_only: true security_opt: - no-new-privileges:true @@ -281,49 +307,25 @@ services: api: condition: service_healthy - # ── Built-in agent sidecar ──────────────────────────────────────────────────── - # Default single instance — always started (local collection mode uses this). - # Chrome is NOT embedded in the image (~200 MB). The agent connects to Chrome - # running on the Docker host via host.docker.internal. - # - # Before starting, ensure Chrome is running on the host with CDP enabled: - # • macOS/Linux: open -a "Google Chrome" --args --remote-debugging-port=9222 - # • Or start the Bridge daemon: node $(npm root -g)/@jackwener/opencli/dist/daemon.js + # ── Built-in interactive browser ───────────────────────────────────────── + # noVNC is loopback-only by default; use an SSH tunnel for remote servers. agent-1: - image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-} + image: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-chrome:${IMAGE_TAG:-0.4.0} environment: - CENTRAL_API_URL: http://api:8000 - # Use container name so the API can reach this agent by DNS within the Docker network - AGENT_ADVERTISE_URL: http://agent-1:19823 - AGENT_PORT: 19823 - # Chrome connection mode: bridge | cdp (override in .env) - AGENT_MODE: ${AGENT_MODE:-bridge} - AGENT_DEPLOY_TYPE: docker - AGENT_LABEL: agent-1 - # Host Chrome endpoints — agent connects to Chrome running on the Docker host - # bridge mode: daemon on host - OPENCLI_DAEMON_HOST: host.docker.internal - OPENCLI_DAEMON_PORT: ${OPENCLI_DAEMON_PORT:-19825} - # cdp mode: Chrome DevTools Protocol on host - OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://host.docker.internal:9222} - OPENCLI_TIMEOUT: ${OPENCLI_TIMEOUT:-120} - API_AUTH_TOKEN: ${API_AUTH_TOKEN:-} - AGENT_API_TOKEN: ${AGENT_API_TOKEN:-} - HTTP_PROXY: ${HTTP_PROXY:-} - HTTPS_PROXY: ${HTTPS_PROXY:-} - extra_hosts: - # Make host.docker.internal resolve to the host on Linux (already works on macOS/Windows) - - "host.docker.internal:host-gateway" + CHROME_HOSTNAME: agent-1 volumes: - - agent_profile_1:/home/agent/.config/chromium + - agent_profile_1:/home/chrome/.config/chromium ports: - - "${AGENT1_PORT:-19823}:19823" # agent_server HTTP API - depends_on: - api: - condition: service_healthy + - "127.0.0.1:${NOVNC_PORT:-6080}:6080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s restart: unless-stopped - # Additional agent instances are managed dynamically via the API + # Additional browser instances are managed dynamically via the API # (节点管理 → 新增实例) # ── Remote agent (all-in-one: Chrome + agent server) ───────────────────── @@ -334,13 +336,13 @@ services: # -e CENTRAL_API_URL=http://:8031 \ # -e AGENT_REGISTER=ws \ # -p 19823:19823 \ - # 2233admin/opencli-admin-agent:0.3.6 + # ghcr.io/2233admin/opencli-admin-agent:0.4.0 # # Or start the bundled agent profile (local network): # docker compose --profile agent up agent agent: profiles: ["agent"] - image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-} + image: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG:-0.4.0}${CHROME_SUFFIX:-} environment: CENTRAL_API_URL: ${CENTRAL_API_URL:-} AGENT_ADVERTISE_URL: ${AGENT_ADVERTISE_URL:-} diff --git a/docs/backend-capability-exposure-matrix.yaml b/docs/backend-capability-exposure-matrix.yaml index 2f4854a..5a4a396 100644 --- a/docs/backend-capability-exposure-matrix.yaml +++ b/docs/backend-capability-exposure-matrix.yaml @@ -1,6 +1,6 @@ version: 1 source: backend.main.app.openapi -openapi_operation_count: 224 +openapi_operation_count: 231 allowed_dispositions: - operator_ui - studio_binding @@ -2254,7 +2254,75 @@ operations: decision: Expose only attested image models inside the Workflow Image Studio. target_epic: Epic 8 capability_id: studio.workflow +- method: GET + path: /api/v1/workspaces/{workspace_id}/operations-agents/{agent_id}/draft + operation_id: get_agent_draft_api_v1_workspaces__workspace_id__operations_agents__agent_id__draft_get + disposition: operator_ui + frontend_route: /operations-agents + wrapper: getOperationsAgentDraft + decision: Load the editable Operations Agent contract in the operator UI. + target_epic: Epic 8 + capability_id: operator.workspace-governance +- method: GET + path: /api/v1/workspaces/{workspace_id}/operations-agents/{agent_id}/versions + operation_id: list_agent_versions_api_v1_workspaces__workspace_id__operations_agents__agent_id__versions_get + disposition: operator_ui + frontend_route: /operations-agents + wrapper: listOperationsAgentVersions + decision: List published Operations Agent versions in the operator UI. + target_epic: Epic 8 + capability_id: operator.workspace-governance +- method: GET + path: /api/v1/workspaces/{workspace_id}/operations-agents/{agent_id}/versions/{version_number} + operation_id: get_agent_version_api_v1_workspaces__workspace_id__operations_agents__agent_id__versions__version_number__get + disposition: operator_ui + frontend_route: /operations-agents + wrapper: getOperationsAgentVersion + decision: Keep version detail available for operator comparison and rollback. + target_epic: Epic 8 + capability_id: operator.workspace-governance +- method: GET + path: /api/v1/workspaces/{workspace_id}/projects/{project_id}/runtime-logs + operation_id: list_project_runtime_logs_api_v1_workspaces__workspace_id__projects__project_id__runtime_logs_get + disposition: studio_binding + frontend_route: /studio/projects/[projectId]/operations + wrapper: listProjectRuntimeLogs + decision: Project runtime logs back the Studio operations view. + target_epic: Epic 8 + capability_id: studio.workflow +- method: GET + path: /api/v1/workspaces/{workspace_id}/projects/{project_id}/runtime-summary + operation_id: get_project_runtime_summary_api_v1_workspaces__workspace_id__projects__project_id__runtime_summary_get + disposition: studio_binding + frontend_route: /studio/projects/[projectId]/operations + wrapper: getProjectRuntimeSummary + decision: Project runtime summary backs the Studio operations view. + target_epic: Epic 8 + capability_id: studio.workflow +- method: GET + path: /api/v1/workspaces/{workspace_id}/projects/{project_id}/workflows/{workflow_id}/runs/{run_id}/trace + operation_id: get_project_runtime_trace_api_v1_workspaces__workspace_id__projects__project_id__workflows__workflow_id__runs__run_id__trace_get + disposition: studio_binding + frontend_route: /studio/projects/[projectId]/operations + wrapper: getProjectRuntimeTrace + decision: Runtime traces back project-scoped Studio run inspection. + target_epic: Epic 8 + capability_id: studio.workflow +- method: POST + path: /api/v1/workspaces/{workspace_id}/projects/{project_id}/workflows/{workflow_id}/runs + operation_id: start_published_workflow_run_api_v1_workspaces__workspace_id__projects__project_id__workflows__workflow_id__runs_post + disposition: studio_binding + frontend_route: /studio/workflow + wrapper: null + decision: Start only published project workflows through the Studio runtime boundary. + target_epic: Epic 8 + capability_id: studio.workflow unreferenced_wrappers: +- wrapper: getOperationsAgentVersion + operation_id: get_agent_version_api_v1_workspaces__workspace_id__operations_agents__agent_id__versions__version_number__get + disposition: operator_ui + target_epic: Epic 8 + decision: Retain for version comparison and rollback detail once the UI exposes it. - wrapper: getWorkspaceSettings operation_id: null disposition: retire diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..34d2915 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +.next +node_modules +.env* +!.env.example +npm-debug.log* +pnpm-debug.log* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..052adfc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +FROM node:22-alpine AS dependencies + +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@11.10.0 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM dependencies AS builder + +ARG BACKEND_URL=http://api:8000 +ENV BACKEND_URL=${BACKEND_URL} \ + NEXT_TELEMETRY_DISABLED=1 +COPY . . +RUN pnpm build + +FROM node:22-alpine AS runtime + +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3010 +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3010 +CMD ["node", "server.js"] diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 753c1ef..5d1f14c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -12,7 +12,7 @@ const eslintConfig = [ }, }, { - ignores: [".next/**", "dist/**", "node_modules/**", "tsconfig.tsbuildinfo"], + ignores: [".next*/**", "dist/**", "node_modules/**", "tsconfig.tsbuildinfo"], }, ] diff --git a/frontend/lib/flow/store-slices.ts b/frontend/lib/flow/store-slices.ts index f4cd890..d73f58d 100644 --- a/frontend/lib/flow/store-slices.ts +++ b/frontend/lib/flow/store-slices.ts @@ -17,7 +17,7 @@ import { updateCanonicalNetworkScope, type CanonicalScopeId, } from "./store-canonical-actions" -import { HISTORY_LIMIT, snapshot } from "./store-utils" +import { HISTORY_LIMIT, snapshot, workflowNodeId } from "./store-utils" import { MAX_WORKFLOW_NODE_DEPTH } from "../workflow/node-hierarchy" import { parseWorkflowProject, type WorkflowProject, type WorkflowProjectNode } from "../workflow/schema" import type { WorkflowEdge, WorkflowNode } from "./types" @@ -466,7 +466,7 @@ export function createSelectionActions( const newNodes = clipboard.nodes.map((n) => { const canonicalNode = canonicalByCanvasId.get(n.id) - const newLocalId = canonicalNode ? nanoid(8) : null + const newLocalId = canonicalNode ? workflowNodeId() : null const newId = newLocalId ? scopeId === null ? newLocalId diff --git a/frontend/lib/flow/store-utils.ts b/frontend/lib/flow/store-utils.ts index ec747e2..fe1e4b6 100644 --- a/frontend/lib/flow/store-utils.ts +++ b/frontend/lib/flow/store-utils.ts @@ -1,7 +1,9 @@ +import { customAlphabet } from "nanoid" import type { FlowSnapshot, FreehandStroke, WorkflowEdge, WorkflowNode } from "./types" import type { WorkflowProject, WorkflowProjectEdge, WorkflowProjectNode } from "../workflow/schema" export const HISTORY_LIMIT = 100 +export const workflowNodeId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 8) export type FlowNetworkStackEntry = { nodeId: string; label: string; snapshot: FlowSnapshot } export type FlowStoreSnapshot = FlowSnapshot & { diff --git a/frontend/lib/flow/store.ts b/frontend/lib/flow/store.ts index 9d4c1bf..18f8474 100644 --- a/frontend/lib/flow/store.ts +++ b/frontend/lib/flow/store.ts @@ -32,7 +32,7 @@ import { createSelectionActions, createWhiteboardActions, } from "./store-slices" -import { snapshot } from "./store-utils" +import { snapshot, workflowNodeId } from "./store-utils" import { PACKAGED_WORKFLOW_PROJECT } from "../workflow/collection-pipeline" import type { WorkflowProject } from "../workflow/schema" import { parseWorkflowProject, type AdapterBinding, type WorkflowProfile, type WorkflowProjectNode } from "../workflow/schema" @@ -923,7 +923,7 @@ export const useFlowStore = create((set, get) => ({ addPrimitiveNode: (item, position, runtimeCapability, options) => { if (!options?.suppressSnapshot) get().takeSnapshot() const { workflowProject, nodes, networkStack } = get() - const localId = `${item.idPrefix}-${nanoid(6)}` + const localId = `${item.idPrefix}-${workflowNodeId(6)}` const freePos = findFreePosition(nodes, position, { width: 196, height: 78 }) const parentNetwork = networkStack.at(-1) const canonicalNode = canonicalNodeFromPrimitive(item, localId, freePos, runtimeCapability) diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index a369ea7..78184f9 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -12,6 +12,7 @@ const FRONTEND_ROOT = path.dirname(fileURLToPath(import.meta.url)) const VIEW_TRANSITIONS_ENABLED = process.env.NEXT_PUBLIC_ENABLE_VIEW_TRANSITIONS !== 'false' const nextConfig = { + output: "standalone", allowedDevOrigins: ['127.0.0.1'], distDir: process.env.OPENCLI_NEXT_DIST_DIR ?? '.next', experimental: { diff --git a/frontend/package.json b/frontend/package.json index aff0d39..477be7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { - "name": "my-project", - "version": "0.1.0", + "name": "opencli-admin-frontend", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev", diff --git a/frontend/scripts/check-workflow-regressions.mjs b/frontend/scripts/check-workflow-regressions.mjs index b6bc2eb..4f54c78 100644 --- a/frontend/scripts/check-workflow-regressions.mjs +++ b/frontend/scripts/check-workflow-regressions.mjs @@ -11,9 +11,10 @@ const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), const repositoryRoot = path.resolve(frontendRoot, '..') const windowsRepositoryPython = path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe') const unixRepositoryPython = path.join(repositoryRoot, '.venv', 'bin', 'python') -const pythonExecutable = existsSync(windowsRepositoryPython) - ? windowsRepositoryPython - : (process.env.PYTHON ?? (existsSync(unixRepositoryPython) ? unixRepositoryPython : 'python')) +const pythonExecutable = process.env.PYTHON + ?? (existsSync(windowsRepositoryPython) + ? windowsRepositoryPython + : (existsSync(unixRepositoryPython) ? unixRepositoryPython : 'python')) registerHooks({ resolve(specifier, context, nextResolve) { @@ -91,6 +92,13 @@ function sourceSection(source, start, end) { return source.slice(startIndex, endIndex) } +test('generated workflow node ids avoid reserved path separators', async () => { + const { workflowNodeId } = await importTypeScript('lib/flow/store-utils.ts') + for (let index = 0; index < 1_000; index += 1) { + assert.doesNotMatch(workflowNodeId(), /::|__/) + } +}) + test('right workflow dock derives its outline from graph structure and opens without a selection', async () => { const [{ buildWorkflowOutlineRows }, shortcuts, inspector, shell, effects] = await Promise.all([ importTypeScript('lib/workflow/workflow-outline.ts'), diff --git a/package-lock.json b/package-lock.json index e042af7..00e03ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencli-admin", - "version": "0.1.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencli-admin", - "version": "0.1.0", + "version": "0.4.0", "workspaces": [ "chrome/extension-src" ], diff --git a/package.json b/package.json index 60e90d6..c82eb2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencli-admin", "private": true, - "version": "0.1.0", + "version": "0.4.0", "workspaces": [ "chrome/extension-src" ], diff --git a/pyproject.toml b/pyproject.toml index d10d8c4..dc8d1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opencli-admin" -version = "0.1.0" +version = "0.4.0" description = "Multi-channel data collection management system" requires-python = ">=3.13" dependencies = [ diff --git a/scripts/chrome-pool.sh b/scripts/chrome-pool.sh index e77c04d..ad45a2c 100755 --- a/scripts/chrome-pool.sh +++ b/scripts/chrome-pool.sh @@ -94,7 +94,7 @@ start_instance() { --network "${NETWORK}" \ --label "${LABEL_KEY}=true" \ --label "chrome.pool.index=${n}" \ - -p "${novnc_port}:6080" \ + -p "127.0.0.1:${novnc_port}:6080" \ -v "${volume}:/home/chrome/.config/chromium" \ --restart unless-stopped \ "${CHROME_IMAGE}" >/dev/null diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index 08c7091..5fd8013 100755 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -29,6 +29,7 @@ # NETBIRD_SETUP_KEY Setup key used to enroll this node into NetBird # NETBIRD_MANAGEMENT_URL Self-hosted NetBird management URL (optional) # NETBIRD_IMAGE_TAG NetBird Docker image tag (default: latest) +# OHMYOPENCLI_REPO Optional audited adapter-pack repository for Python installs # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail @@ -51,7 +52,7 @@ AGENT_MODE="${AGENT_MODE:-cdp}" IMAGE_TAG="${IMAGE_TAG:-__IMAGE_TAG__}" INSTALL_CHROME="${INSTALL_CHROME:-false}" OPENCLI_BROWSER_PROFILE_KIND="${OPENCLI_BROWSER_PROFILE_KIND:-authenticated}" -OHMYOPENCLI_REPO="${OHMYOPENCLI_REPO:-https://github.com/2233admin/OhMyOpenCLI.git}" +OHMYOPENCLI_REPO="${OHMYOPENCLI_REPO:-}" INSTALL_MODE="${1:-docker}" [[ "$CENTRAL_API_URL" == "__CENTRAL_API_URL__" ]] && CENTRAL_API_URL="" @@ -81,7 +82,7 @@ if [[ "$INSTALL_CHROME" == "true" ]]; then else CHROME_SUFFIX="" fi -AGENT_IMAGE="${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG}${CHROME_SUFFIX}" +AGENT_IMAGE="${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG}${CHROME_SUFFIX}" # ───────────────────────────────────────────────────────────────────────────── @@ -331,18 +332,21 @@ install_python() { warn " Install Node.js 22+ from https://nodejs.org then run: npm install -g @jackwener/opencli@1.8.5" fi - # Install the exact project-owned managed-acquisition capability package. - OHMYOPENCLI_ROOT="$AGENT_DIR/ohmyopencli" - OHMYOPENCLI_COMMIT="73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" - OFFICIAL_SITE_CAPABILITY_COMMIT="73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" - command -v git >/dev/null 2>&1 || die "git is required to install OhMyOpenCLI" - [[ -e "$OHMYOPENCLI_ROOT" ]] && die \ - "Managed OhMyOpenCLI target already exists; archive it explicitly before reinstalling: $OHMYOPENCLI_ROOT" - git clone "$OHMYOPENCLI_REPO" "$OHMYOPENCLI_ROOT" - git -C "$OHMYOPENCLI_ROOT" checkout --detach "$OHMYOPENCLI_COMMIT" - git -C "$OHMYOPENCLI_ROOT" merge-base --is-ancestor \ - "$OFFICIAL_SITE_CAPABILITY_COMMIT" HEAD - (cd "$OHMYOPENCLI_ROOT" && npm ci && npm run bootstrap) + # Organization-specific adapter packs are optional and never fetched implicitly. + OHMYOPENCLI_ROOT="" + if [[ -n "$OHMYOPENCLI_REPO" ]]; then + OHMYOPENCLI_ROOT="$AGENT_DIR/ohmyopencli" + OHMYOPENCLI_COMMIT="${OHMYOPENCLI_COMMIT:-73cc60c83586ef2c95469b3b70d6cfc80fa5bc53}" + OFFICIAL_SITE_CAPABILITY_COMMIT="${OFFICIAL_SITE_CAPABILITY_COMMIT:-$OHMYOPENCLI_COMMIT}" + command -v git >/dev/null 2>&1 || die "git is required to install the adapter pack" + [[ -e "$OHMYOPENCLI_ROOT" ]] && die \ + "Adapter-pack target already exists; archive it explicitly before reinstalling: $OHMYOPENCLI_ROOT" + git clone "$OHMYOPENCLI_REPO" "$OHMYOPENCLI_ROOT" + git -C "$OHMYOPENCLI_ROOT" checkout --detach "$OHMYOPENCLI_COMMIT" + git -C "$OHMYOPENCLI_ROOT" merge-base --is-ancestor \ + "$OFFICIAL_SITE_CAPABILITY_COMMIT" HEAD + (cd "$OHMYOPENCLI_ROOT" && npm ci && npm run bootstrap) + fi # ── Find Chrome binary ──────────────────────────────────────────────────── find_chrome() { @@ -432,7 +436,7 @@ Environment=AGENT_LABEL=${AGENT_LABEL} Environment=AGENT_MODE=${AGENT_MODE} Environment=AGENT_API_TOKEN=${AGENT_API_TOKEN} Environment=AGENT_DEPLOY_TYPE=shell -Environment=OHMYOPENCLI_ROOT=${OHMYOPENCLI_ROOT} +$([ -n "$OHMYOPENCLI_ROOT" ] && echo "Environment=OHMYOPENCLI_ROOT=${OHMYOPENCLI_ROOT}") Environment=OPENCLI_BROWSER_PROFILE_KIND=${OPENCLI_BROWSER_PROFILE_KIND} $([ -n "${OPENCLI_CDP_ENDPOINT:-}" ] && echo "Environment=OPENCLI_CDP_ENDPOINT=${OPENCLI_CDP_ENDPOINT}") $([ -n "${HTTP_PROXY:-}" ] && echo "Environment=HTTP_PROXY=${HTTP_PROXY}") @@ -452,7 +456,8 @@ EOF export CENTRAL_API_URL AGENT_REGISTER AGENT_PORT AGENT_ADVERTISE_URL AGENT_LABEL AGENT_MODE export AGENT_API_TOKEN export AGENT_DEPLOY_TYPE=shell - export OHMYOPENCLI_ROOT OPENCLI_BROWSER_PROFILE_KIND + export OPENCLI_BROWSER_PROFILE_KIND + [[ -n "$OHMYOPENCLI_ROOT" ]] && export OHMYOPENCLI_ROOT [[ -n "${OPENCLI_CDP_ENDPOINT:-}" ]] && export OPENCLI_CDP_ENDPOINT [[ -n "${HTTP_PROXY:-}" ]] && export HTTP_PROXY [[ -n "${HTTPS_PROXY:-}" ]] && export HTTPS_PROXY diff --git a/scripts/install-managed-opencli.ps1 b/scripts/install-managed-opencli.ps1 index 0616605..6cbfa09 100644 --- a/scripts/install-managed-opencli.ps1 +++ b/scripts/install-managed-opencli.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory = $true)] [string]$CentralApiUrl, [string]$ApiAuthToken = "", - [string]$OhMyOpenCliRepo = "https://github.com/2233admin/OhMyOpenCLI.git", + [Parameter(Mandatory = $true)] + [string]$OhMyOpenCliRepo, [string]$OhMyOpenCliRoot = "$env:LOCALAPPDATA\opencli-admin\OhMyOpenCLI" ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..f5e1f38 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,120 @@ +param( + [string]$Version = $(if ($env:OPENCLI_ADMIN_VERSION) { $env:OPENCLI_ADMIN_VERSION } else { "0.4.0" }), + [string]$Repository = $(if ($env:OPENCLI_ADMIN_REPOSITORY) { $env:OPENCLI_ADMIN_REPOSITORY } else { "2233admin/opencli-admin" }), + [string]$InstallDir = $(if ($env:OPENCLI_ADMIN_DIR) { $env:OPENCLI_ADMIN_DIR } else { Join-Path (Get-Location) "opencli-admin" }) +) + +$ErrorActionPreference = "Stop" + +function Assert-NativeSuccess([string]$Message) { + if ($LASTEXITCODE -ne 0) { + throw $Message + } +} + +docker compose version | Out-Null +Assert-NativeSuccess "Docker Compose is required." +docker info | Out-Null +Assert-NativeSuccess "Docker is not running." + +$resolvedInstallDir = [System.IO.Path]::GetFullPath($InstallDir) +if (Test-Path -LiteralPath $resolvedInstallDir) { + $existing = Get-ChildItem -LiteralPath $resolvedInstallDir -Force + if ($existing.Count -gt 0) { + throw "Install directory is not empty: $resolvedInstallDir" + } +} else { + New-Item -ItemType Directory -Path $resolvedInstallDir | Out-Null +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("opencli-admin-" + [guid]::NewGuid()) +$archive = "$tempRoot.zip" +$expanded = Join-Path $tempRoot "expanded" +New-Item -ItemType Directory -Path $expanded | Out-Null + +try { + Invoke-WebRequest "https://github.com/$Repository/archive/refs/tags/v$Version.zip" -OutFile $archive -UseBasicParsing + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $sourceRoot = Get-ChildItem -LiteralPath $expanded -Directory | Select-Object -First 1 + if (-not $sourceRoot) { + throw "The release archive did not contain a project directory." + } + Get-ChildItem -LiteralPath $sourceRoot.FullName -Force | Move-Item -Destination $resolvedInstallDir +} finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } +} + +$envPath = Join-Path $resolvedInstallDir ".env" +Copy-Item -LiteralPath (Join-Path $resolvedInstallDir ".env.docker.example") -Destination $envPath + +function New-RandomBytes([int]$Count) { + $bytes = New-Object byte[] $Count + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + try { + $rng.GetBytes($bytes) + } finally { + $rng.Dispose() + } + return ,$bytes +} + +function New-HexSecret([int]$Bytes) { + return -join ((New-RandomBytes $Bytes) | ForEach-Object { $_.ToString("x2") }) +} + +function New-FernetKey { + return [Convert]::ToBase64String((New-RandomBytes 32)).Replace("+", "-").Replace("/", "_") +} + +function Set-EnvValue([string]$Key, [string]$Value) { + $content = [IO.File]::ReadAllText($envPath) + $content = [Text.RegularExpressions.Regex]::Replace( + $content, + "(?m)^$([Text.RegularExpressions.Regex]::Escape($Key))=.*$", + "$Key=$Value" + ) + [IO.File]::WriteAllText($envPath, $content, [Text.UTF8Encoding]::new($false)) +} + +$apiToken = New-HexSecret 32 +$bootstrapToken = New-HexSecret 32 +Set-EnvValue "API_AUTH_TOKEN" $apiToken +Set-EnvValue "BOOTSTRAP_ADMIN_TOKEN" $bootstrapToken +Set-EnvValue "SECRET_KEY" (New-HexSecret 32) +Set-EnvValue "CREDENTIAL_ENCRYPTION_KEY" (New-FernetKey) + +Push-Location $resolvedInstallDir +try { + docker compose pull api frontend agent-1 + Assert-NativeSuccess "Failed to pull OpenCLI Admin images." + docker compose up -d + Assert-NativeSuccess "Failed to start OpenCLI Admin." + + $frontendPort = if ($env:FRONTEND_PORT) { $env:FRONTEND_PORT } else { "3010" } + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + try { + Invoke-WebRequest "http://localhost:$frontendPort/login" -UseBasicParsing | Out-Null + $ready = $true + break + } catch { + Start-Sleep -Seconds 5 + } + } + if (-not $ready) { + docker compose ps + docker compose logs --tail=100 api frontend + throw "OpenCLI Admin did not become healthy within 5 minutes." + } +} finally { + Pop-Location +} + +Write-Host "" +Write-Host "OpenCLI Admin $Version is ready." +Write-Host "URL: http://localhost:$frontendPort" +Write-Host "BOOTSTRAP_ADMIN_TOKEN: $bootstrapToken" +Write-Host "API_AUTH_TOKEN: $apiToken" +Write-Host "Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in $envPath" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..a79c9a3 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env sh +set -eu + +VERSION="${OPENCLI_ADMIN_VERSION:-0.4.0}" +REPOSITORY="${OPENCLI_ADMIN_REPOSITORY:-2233admin/opencli-admin}" +INSTALL_DIR="${OPENCLI_ADMIN_DIR:-$PWD/opencli-admin}" + +for command_name in docker curl tar; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "Missing required command: $command_name" >&2 + exit 1 + } +done +docker compose version >/dev/null +docker info >/dev/null + +if [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + echo "Install directory is not empty: $INSTALL_DIR" >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +archive="$(mktemp)" +trap 'rm -f "$archive"' EXIT +curl -fsSL "https://github.com/${REPOSITORY}/archive/refs/tags/v${VERSION}.tar.gz" -o "$archive" +tar -xzf "$archive" --strip-components=1 -C "$INSTALL_DIR" +cp "$INSTALL_DIR/.env.docker.example" "$INSTALL_DIR/.env" + +random_hex() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$1" + else + docker run --rm python:3.13-alpine python -c \ + "import secrets; print(secrets.token_hex($1))" + fi +} + +random_fernet() { + if command -v openssl >/dev/null 2>&1; then + raw="$(openssl rand -base64 32)" || return 1 + if [ -z "$raw" ]; then + echo "openssl rand returned an empty encryption key" >&2 + return 1 + fi + printf '%s' "$raw" | tr '/+' '_-' | tr -d '\r\n' + else + docker run --rm python:3.13-alpine python -c \ + "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())" + fi +} + +replace_env() { + key="$1" + value="$2" + temp_file="${INSTALL_DIR}/.env.tmp" + awk -v key="$key" -v value="$value" ' + index($0, key "=") == 1 { print key "=" value; next } + { print } + ' "$INSTALL_DIR/.env" > "$temp_file" + mv "$temp_file" "$INSTALL_DIR/.env" +} + +api_token="$(random_hex 32)" +bootstrap_token="$(random_hex 32)" +credential_encryption_key="$(random_fernet)" +if [ -z "$credential_encryption_key" ]; then + echo "Failed to generate CREDENTIAL_ENCRYPTION_KEY" >&2 + exit 1 +fi +replace_env API_AUTH_TOKEN "$api_token" +replace_env BOOTSTRAP_ADMIN_TOKEN "$bootstrap_token" +replace_env SECRET_KEY "$(random_hex 32)" +replace_env CREDENTIAL_ENCRYPTION_KEY "$credential_encryption_key" +chmod 600 "$INSTALL_DIR/.env" + +cd "$INSTALL_DIR" +docker compose pull api frontend agent-1 +docker compose up -d + +attempt=0 +until curl -fsS "http://localhost:${FRONTEND_PORT:-3010}/login" >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + docker compose ps + docker compose logs --tail=100 api frontend + echo "OpenCLI Admin did not become healthy within 5 minutes." >&2 + exit 1 + fi + sleep 5 +done + +printf '\nOpenCLI Admin %s is ready.\n' "$VERSION" +printf 'URL: http://localhost:%s\n' "${FRONTEND_PORT:-3010}" +printf 'BOOTSTRAP_ADMIN_TOKEN: %s\n' "$bootstrap_token" +printf 'API_AUTH_TOKEN: %s\n' "$api_token" +printf 'Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in %s/.env\n' "$INSTALL_DIR" diff --git a/tests/integration/test_legacy_native_intelligence_migration.py b/tests/integration/test_legacy_native_intelligence_migration.py index a969a90..d4fb6b0 100644 --- a/tests/integration/test_legacy_native_intelligence_migration.py +++ b/tests/integration/test_legacy_native_intelligence_migration.py @@ -65,7 +65,7 @@ def test_legacy_plugin_head_rejoins_native_intelligence_head(tmp_path: Path) -> ) } - assert revision == ("i6j7k8l9m0n1",) + assert revision == ("k8l9m0n1o2p3",) assert marker == ("workspace-1", "native-intelligence-workspace") assert "intelligence_sessions" in tables assert "intelligence_artifacts" in tables diff --git a/tests/integration/test_legacy_plugin_migration.py b/tests/integration/test_legacy_plugin_migration.py index a41fdcf..ef51943 100644 --- a/tests/integration/test_legacy_plugin_migration.py +++ b/tests/integration/test_legacy_plugin_migration.py @@ -87,7 +87,7 @@ def test_legacy_plugin_database_rejoins_current_migration_head(tmp_path: Path) - finally: connection.close() - assert revision == ("i6j7k8l9m0n1",) + assert revision == ("k8l9m0n1o2p3",) assert "version" in cursor_columns assert "identity_key" in record_columns assert "ix_collected_records_source_identity" in record_indexes @@ -135,7 +135,7 @@ def test_current_database_repairs_missing_plugin_installation_table(tmp_path: Pa finally: connection.close() - assert revision == ("i6j7k8l9m0n1",) + assert revision == ("k8l9m0n1o2p3",) assert table == ("plugin_installations",) assert "ix_plugin_installations_provider_key" in indexes @@ -184,7 +184,7 @@ def test_current_head_repairs_missing_record_identity_schema(tmp_path: Path) -> finally: connection.close() - assert revision == ("i6j7k8l9m0n1",) + assert revision == ("k8l9m0n1o2p3",) assert "identity_key" in columns assert "ix_collected_records_source_identity" in indexes assert record == ("source-1", None) diff --git a/tests/integration/test_workflow_capabilities_api.py b/tests/integration/test_workflow_capabilities_api.py index 624ab69..e6f574a 100644 --- a/tests/integration/test_workflow_capabilities_api.py +++ b/tests/integration/test_workflow_capabilities_api.py @@ -465,6 +465,8 @@ async def test_workflow_capabilities_project_real_backend_surfaces(client, monke "browser_act", "cli", "crawl4ai", + "doubao_research", + "douyin_detail", "opencli", "rss", "skill", diff --git a/tests/unit/test_agent_image_runtime_packaging.py b/tests/unit/test_agent_image_runtime_packaging.py index 000bb76..eb1f907 100644 --- a/tests/unit/test_agent_image_runtime_packaging.py +++ b/tests/unit/test_agent_image_runtime_packaging.py @@ -11,65 +11,28 @@ def test_agent_image_packages_runtime_adapter_modules(): assert "COPY backend/miniflow/ ./backend/miniflow/" in dockerfile -def test_agent_image_pins_managed_acquisition_runtime(): - dockerfile = (ROOT / "agent" / "Dockerfile").read_text(encoding="utf-8") - - assert "ARG OPENCLI_VERSION=1.8.5" in dockerfile - assert ( - "ARG OHMYOPENCLI_COMMIT=" - "73cc60c83586ef2c95469b3b70d6cfc80fa5bc53" - ) in dockerfile - assert "git checkout --detach ${OHMYOPENCLI_COMMIT}" in dockerfile - assert "npm ci" in dockerfile - assert "npm run bootstrap" in dockerfile - assert "OHMYOPENCLI_ROOT=/opt/ohmyopencli" in dockerfile - assert "COPY scripts/patch-opencli.js /tmp/patch-opencli.js" in dockerfile - assert "node /tmp/patch-opencli.js" in dockerfile - - -def test_managed_runtime_plugin_is_registered_for_each_final_image_user(): - cases = [ - (ROOT / "Dockerfile", "appuser", "/home/appuser"), - (ROOT / "agent" / "Dockerfile", "agent", "/home/agent"), - ] - - for path, user, home in cases: - dockerfile = path.read_text(encoding="utf-8") - user_creation = dockerfile.index(f"useradd -m -u 1000 {user}") - user_bootstrap = dockerfile.index(f"HOME={home} npm run bootstrap") - final_user = dockerfile.index(f"USER {user}") +def test_public_images_package_opencli_without_a_private_checkout(): + main_image = (ROOT / "Dockerfile").read_text(encoding="utf-8") + agent_image = (ROOT / "agent" / "Dockerfile").read_text(encoding="utf-8") - assert user_creation < user_bootstrap < final_user + for dockerfile in (main_image, agent_image): + assert "ARG OPENCLI_VERSION=1.8.5" in dockerfile + assert "npm install -g @jackwener/opencli@${OPENCLI_VERSION}" in dockerfile + assert "node /tmp/patch-opencli.js" in dockerfile + assert "2233admin/OhMyOpenCLI" not in dockerfile + assert "git clone ${OHMYOPENCLI_REPO}" not in dockerfile -def test_every_installer_allows_an_audited_ohmyopencli_source_override(): - main_image = (ROOT / "Dockerfile").read_text(encoding="utf-8") - agent_image = (ROOT / "agent" / "Dockerfile").read_text(encoding="utf-8") +def test_native_adapter_pack_install_requires_an_explicit_repository(): windows = (ROOT / "scripts" / "install-managed-opencli.ps1").read_text( encoding="utf-8" ) linux = (ROOT / "scripts" / "install-agent.sh").read_text(encoding="utf-8") - assert "ARG OHMYOPENCLI_REPO=" in main_image - assert "git clone ${OHMYOPENCLI_REPO}" in main_image - assert "ARG OHMYOPENCLI_REPO=" in agent_image - assert "git clone ${OHMYOPENCLI_REPO}" in agent_image - assert '[string]$OhMyOpenCliRepo = "https://github.com/2233admin/OhMyOpenCLI.git"' in windows - assert "git clone $OhMyOpenCliRepo $OhMyOpenCliRoot" in windows - assert ( - 'OHMYOPENCLI_REPO="${OHMYOPENCLI_REPO:-' - 'https://github.com/2233admin/OhMyOpenCLI.git}"' - ) in linux - assert 'git clone "$OHMYOPENCLI_REPO" "$OHMYOPENCLI_ROOT"' in linux - - -def test_both_linux_images_package_the_cross_platform_readiness_trace_verifier(): - for path in (ROOT / "Dockerfile", ROOT / "agent" / "Dockerfile"): - dockerfile = path.read_text(encoding="utf-8") - assert ( - "COPY scripts/verify_managed_opencli_runtime.py " - "./scripts/verify_managed_opencli_runtime.py" - ) in dockerfile + assert '[string]$OhMyOpenCliRepo,' in windows + assert "2233admin/OhMyOpenCLI" not in windows + assert 'OHMYOPENCLI_REPO="${OHMYOPENCLI_REPO:-}"' in linux + assert 'if [[ -n "$OHMYOPENCLI_REPO" ]]; then' in linux def test_anonymous_agent_profiles_are_fresh_per_agent_start(): diff --git a/tests/unit/test_image_studio_deployment_contract.py b/tests/unit/test_image_studio_deployment_contract.py index e394f69..2818385 100644 --- a/tests/unit/test_image_studio_deployment_contract.py +++ b/tests/unit/test_image_studio_deployment_contract.py @@ -16,13 +16,15 @@ def test_invokeai_settings_are_server_only_and_fail_closed() -> None: assert settings.image_asset_storage_path -def test_invokeai_compose_service_is_private_and_digest_pinned() -> None: +def test_invokeai_compose_service_is_private_and_fails_closed_without_an_image() -> None: compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) service = compose["services"]["invokeai"] assert service["profiles"] == ["image-studio"] assert "ports" not in service - assert service["image"].startswith("${INVOKEAI_ATTESTED_IMAGE:?") + assert service["image"].startswith( + "${INVOKEAI_ATTESTED_IMAGE:-invalid.invalid/" + ) assert "@sha256:" in service["image"] assert service["read_only"] is True assert service["security_opt"] == ["no-new-privileges:true"] diff --git a/tests/unit/test_public_release_contract.py b/tests/unit/test_public_release_contract.py new file mode 100644 index 0000000..75c4774 --- /dev/null +++ b/tests/unit/test_public_release_contract.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def source(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_public_release_has_a_runnable_frontend_and_safe_compose_defaults() -> None: + compose = source("docker-compose.yml") + frontend_config = source("frontend/next.config.mjs") + + assert "\n frontend:\n" in compose + assert "\n agent-1:\n" in compose + assert "opencli-admin-frontend:${IMAGE_TAG:-0.4.0}" in compose + assert "opencli-admin-chrome:${IMAGE_TAG:-0.4.0}" in compose + assert '"127.0.0.1:${NOVNC_PORT:-6080}:6080"' in compose + assert "CHROME_IMAGE:" in compose + assert "./backend:/app/backend" not in compose + assert "${INVOKEAI_ATTESTED_IMAGE:?" not in compose + assert "${API_AUTH_TOKEN:?" in compose + assert "${BOOTSTRAP_ADMIN_TOKEN:?" in compose + assert 'output: "standalone"' in frontend_config + assert (ROOT / "frontend" / "Dockerfile").is_file() + + +def test_public_release_has_one_ci_frontend_job_and_installers() -> None: + workflow = source(".github/workflows/ci.yml") + release_workflow = source(".github/workflows/release.yml") + windows_installer = source("scripts/install.ps1") + unix_installer = source("scripts/install.sh") + + assert workflow.count("\n frontend:\n") == 1 + assert (ROOT / "scripts" / "install.sh").is_file() + assert (ROOT / "scripts" / "install.ps1").is_file() + assert (ROOT / ".env.docker.example").is_file() + assert "BOOTSTRAP_ADMIN_TOKEN" in source(".env.docker.example") + assert "BOOTSTRAP_ADMIN_TOKEN" in unix_installer + assert "BOOTSTRAP_ADMIN_TOKEN" in windows_installer + assert 'os.environ.get("NOVNC_BASE_PORT", 6080)' in source( + "backend/api/v1/browsers.py" + ) + assert "Assert-NativeSuccess" in windows_installer + assert "-UseBasicParsing" in windows_installer + assert "http://localhost:$frontendPort/login" in windows_installer + assert 'raw="$(openssl rand -base64 32)" || return 1' in unix_installer + assert 'if [ -z "$credential_encryption_key" ]; then' in unix_installer + assert "packages: write" in release_workflow + assert "id-token: write" not in release_workflow diff --git a/tests/unit/test_workers_api.py b/tests/unit/test_workers_api.py index 74630e0..ad3e598 100644 --- a/tests/unit/test_workers_api.py +++ b/tests/unit/test_workers_api.py @@ -160,7 +160,7 @@ async def test_registered_anonymous_profile_is_visible_in_pool_inventory(client) { "url": "http://clean-agent:19823", "available": True, - "novnc_port": 3010, + "novnc_port": 6080, "container_status": "running", "mode": "cdp", "agent_url": "http://clean-agent:19823", diff --git a/uv.lock b/uv.lock index 3a74402..0e9a93c 100644 --- a/uv.lock +++ b/uv.lock @@ -1944,7 +1944,7 @@ wheels = [ [[package]] name = "opencli-admin" -version = "0.1.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "aiosmtplib" },