From 8423cf3eeb8f3fd709ddbf03980c5f46422833a2 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:24:57 +0800 Subject: [PATCH] fix(channels): complete dependency manifest migration (#4995) * fix(channels): complete dependency manifest migration * docs(docker): clarify custom uid dependency installs * fix(channels): keep dependency preinstall internal * refactor(channels): move dependency installer to scripts * fix(docker): limit runtime write access --- .github/workflows/ci.yml | 30 +++++++++++-- Dockerfile | 30 ++++++++++--- docker-compose.yml | 2 + docs/channel-package-guide.md | 3 +- docs/chat-apps.md | 6 +-- docs/configuration.md | 2 + docs/deployment.md | 22 ++++++++++ nanobot/skills/update-setup/SKILL.md | 2 +- scripts/install_channel_dependencies.py | 36 ++++++++++++++++ tests/channels/test_channel_plugins.py | 57 +++++++++++++++++++++++++ 10 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 scripts/install_channel_dependencies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0549d04b..267a8bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,21 +57,26 @@ jobs: - name: Install dependencies run: uv sync --all-extras --dev + - name: Install channel dependencies + run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels + + # Channel requirements live in manifests rather than uv.lock. Avoid a + # later uv run sync pruning the packages installed by the previous step. - name: Lint with ruff if: matrix.coverage - run: uv run ruff check nanobot tests conftest.py + run: uv run --no-sync ruff check nanobot tests conftest.py - name: Run tests with coverage if: matrix.coverage run: >- - uv run python -m pytest + uv run --no-sync python -m pytest --cov=nanobot --cov-report=term-missing:skip-covered --durations=25 --durations-min=1.0 - name: Run compatibility tests if: ${{ !matrix.coverage }} run: >- - uv run python -m pytest + uv run --no-sync python -m pytest --durations=25 --durations-min=1.0 webui: @@ -105,3 +110,22 @@ jobs: - name: Build WebUI working-directory: webui run: bun run build + + docker: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Build image with default channel dependencies + run: docker build -t nanobot:test . + + - name: Verify default WhatsApp dependencies + run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno" + + - name: Verify runtime dependency permissions + run: >- + docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c + 'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot && + python -m scripts.install_channel_dependencies discord && python -c "import discord"' diff --git a/Dockerfile b/Dockerfile index 3078be2f..6cca60fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,18 +15,38 @@ RUN apt-get update && \ WORKDIR /app +# Keep the runtime environment writable by the non-root nanobot user. Enabled +# channels may install their manifest-declared dependencies at startup. +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="/app/.venv/bin:$PATH" +RUN uv venv --seed "$VIRTUAL_ENV" + # Install Python dependencies first (cached layer). Hatch reads the custom build # hook from hatch_build.py even for this metadata-only install. -ARG NANOBOT_EXTRAS=whatsapp +ARG NANOBOT_EXTRAS= COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./ RUN mkdir -p nanobot && touch nanobot/__init__.py && \ - NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \ + if [ -n "$NANOBOT_EXTRAS" ]; then \ + NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \ + --python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \ + else \ + NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \ + --python "$VIRTUAL_ENV/bin/python" --no-cache .; \ + fi && \ rm -rf nanobot # Copy the full source and install COPY nanobot/ nanobot/ +COPY scripts/install_channel_dependencies.py scripts/ COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/ -RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" +RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache . + +# Preinstall selected channel dependencies from their manifests. A comma-separated +# list keeps the image configurable while preserving WhatsApp in the default image. +ARG NANOBOT_CHANNELS=whatsapp +RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \ + python -m scripts.install_channel_dependencies "$channel"; \ + done # Render deploy template (see render.yaml): committed gateway config that wires # secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved @@ -34,10 +54,10 @@ RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EX # won't shadow it. Only used when RENDER=true; ignored by local runs. COPY render-config.json ./ -# Create non-root user and config directory +# Create the non-root user and hand ownership of the writable virtualenv to it. RUN useradd -m -u 1000 -s /bin/bash nanobot && \ mkdir -p /home/nanobot/.nanobot && \ - chown -R nanobot:nanobot /home/nanobot /app + chown -R nanobot:nanobot /home/nanobot /app/.venv COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 4c2c0486..96d138e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ x-common-config: &common-config build: context: . dockerfile: Dockerfile + args: + NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp} volumes: - ~/.nanobot:/home/nanobot/.nanobot cap_drop: diff --git a/docs/channel-package-guide.md b/docs/channel-package-guide.md index df6ce326..d53904b3 100644 --- a/docs/channel-package-guide.md +++ b/docs/channel-package-guide.md @@ -235,7 +235,7 @@ Do not add a runtime module directly under `nanobot/channels/`, create a paralle `manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker. -The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance. +The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, dependency requirements, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance. Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels//connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection. @@ -777,6 +777,7 @@ git clone https://github.com/HKUDS/nanobot.git cd nanobot python -m pip install -e . nanobot plugins list # should show the package as "webhook" +nanobot plugins enable webhook nanobot gateway # test end-to-end ``` diff --git a/docs/chat-apps.md b/docs/chat-apps.md index c8c57d7c..afade5ce 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -46,8 +46,8 @@ The sections below explain what each chat platform requires and provide manual c > [!NOTE] > If you are upgrading from a version where chat app SDKs were installed by default, -> install the channel extra in the same Python environment before enabling or -> restarting that channel: +> enable the channel in the same Python environment so nanobot installs its +> manifest-declared dependencies: > > ```bash > nanobot plugins enable @@ -185,7 +185,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. nanobot plugins enable mochat ``` -Without this extra, Mochat still works through HTTP polling. +Without these dependencies, Mochat still works through HTTP polling. **1. Ask nanobot to set up Mochat for you** diff --git a/docs/configuration.md b/docs/configuration.md index 08408ffb..150e90e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,6 +204,8 @@ These variables are process-level switches. Set them in the same terminal, servi | `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. | | `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. | | `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. | +| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. | +| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. | | `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. | Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface. diff --git a/docs/deployment.md b/docs/deployment.md index 9647e7af..fa06dfaf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -62,6 +62,22 @@ Restart the deployed process after editing `config.json`. Long-running processes ### Docker Compose +The default image preinstalls WhatsApp dependencies. To bake other enabled +channels into an image (recommended for deployments without PyPI access), pass +a comma-separated `NANOBOT_CHANNELS` build argument: + +```bash +NANOBOT_CHANNELS=telegram,slack docker compose build +``` + +The image keeps nanobot in a virtual environment owned by its built-in non-root +runtime user (UID 1000). If an enabled channel was not preinstalled, gateway +startup can therefore install its manifest-declared dependencies. Rebuilding +with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying +on the container's writable layer. If you override the container with a +different `--user`, bake every enabled channel into the image because that UID +is not guaranteed write access to the virtual environment. + ```bash docker compose run --rm nanobot-cli onboard # first-time setup vim ~/.nanobot/config.json # add API keys @@ -94,6 +110,12 @@ bwrap sandbox is enabled. # Build the image docker build -t nanobot . +# Or preinstall a regular Python extra such as Bedrock support +docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot . + +# Or preinstall dependencies for a specific set of channels +docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot . + # Initialize config (first time only) docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard diff --git a/nanobot/skills/update-setup/SKILL.md b/nanobot/skills/update-setup/SKILL.md index ab19a977..bc8e5019 100644 --- a/nanobot/skills/update-setup/SKILL.md +++ b/nanobot/skills/update-setup/SKILL.md @@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path: **Question 2 — Optional dependencies:** ``` -question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, langfuse, olostep. Channel dependencies are installed from their manifests when the WebUI gateway starts." +question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, langfuse, olostep. Channel dependencies are installed from their manifests when the gateway starts." ``` Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names. diff --git a/scripts/install_channel_dependencies.py b/scripts/install_channel_dependencies.py new file mode 100644 index 00000000..73c9f175 --- /dev/null +++ b/scripts/install_channel_dependencies.py @@ -0,0 +1,36 @@ +"""Install channel manifest dependencies for repository build and CI jobs.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +from nanobot.channels.registry import discover_plugins +from nanobot.optional_features import ensure_enabled_channel_dependencies + + +def main(argv: Sequence[str] | None = None) -> int: + """Install selected channel dependencies without changing channel configuration.""" + args = list(sys.argv[1:] if argv is None else argv) + if not args: + print("Pass channel names or --all-channels.", file=sys.stderr) + return 2 + if "--all-channels" in args and args != ["--all-channels"]: + print("Pass channel names or --all-channels, not both.", file=sys.stderr) + return 2 + + plugins = discover_plugins() + names = set(plugins) if args == ["--all-channels"] else set(args) + unknown = sorted(names - set(plugins)) + if unknown: + print(f"Unknown channels: {', '.join(unknown)}", file=sys.stderr) + return 2 + + failures = ensure_enabled_channel_dependencies(names, plugins) + for name, message in sorted(failures.items()): + print(f"{name}: {message}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index a77eb629..df7bac3b 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -1499,6 +1499,63 @@ def test_plugins_enable_skips_install_when_extra_is_present(monkeypatch, tmp_pat assert not config_path.exists() +def test_repository_dependency_installer_selects_all_channel_manifests(monkeypatch): + from scripts import install_channel_dependencies as dependencies + + plugins = { + "second": ChannelPlugin( + name="second", + display_name="Second", + runtime="missing.second.runtime:SecondChannel", + dependencies=("second-sdk>=2",), + ), + "first": ChannelPlugin( + name="first", + display_name="First", + runtime="missing.first.runtime:FirstChannel", + dependencies=("first-sdk>=1",), + ), + } + prepared: list[tuple[set[str], dict[str, ChannelPlugin]]] = [] + monkeypatch.setattr(dependencies, "discover_plugins", lambda: plugins) + monkeypatch.setattr( + dependencies, + "ensure_enabled_channel_dependencies", + lambda names, discovered: prepared.append((names, discovered)) or {}, + ) + + assert dependencies.main(["--all-channels"]) == 0 + assert prepared == [(set(plugins), plugins)] + + +def test_repository_dependency_installer_rejects_unknown_channel(monkeypatch, capsys): + from scripts import install_channel_dependencies as dependencies + + monkeypatch.setattr(dependencies, "discover_plugins", lambda: {}) + + assert dependencies.main(["missing"]) == 2 + assert "Unknown channels: missing" in capsys.readouterr().err + + +def test_repository_dependency_installer_propagates_install_failure(monkeypatch, capsys): + from scripts import install_channel_dependencies as dependencies + + plugin = ChannelPlugin( + name="demo", + display_name="Demo", + runtime="missing.demo.runtime:DemoChannel", + ) + monkeypatch.setattr(dependencies, "discover_plugins", lambda: {"demo": plugin}) + monkeypatch.setattr( + dependencies, + "ensure_enabled_channel_dependencies", + lambda _names, _plugins: {"demo": "dependency install failed"}, + ) + + assert dependencies.main(["demo"]) == 1 + assert "demo: dependency install failed" in capsys.readouterr().err + + def test_plugins_disable_channel_writes_config(monkeypatch, tmp_path): from typer.testing import CliRunner