Compare commits

...
Author SHA1 Message Date
Xubin Ren 452f5e2214 test(websocket): isolate persisted runtime data 2026-08-14 03:36:49 +09:00
Xubin Ren 6807f915e1 fix(webui): bound restored session previews 2026-08-14 03:15:38 +09:00
Xubin Ren 2e61fbc889 refactor(webui): simplify transcript discovery 2026-08-14 03:11:27 +09:00
Xubin Ren 0e42166bb1 fix(webui): restore transcript-only session history 2026-08-14 03:09:06 +09:00
Bobbyandchengyongru af582246f1 [Security] exec.allowPatterns shell-chain bypass allows unintended command execution
Closes #5306
2026-08-13 11:05:04 +08:00
aiguozhi123456andchengyongru e07ecc8cc5 fix(weixin): persist QR-login token to config.json on connect
When config.json has no channels configuration, the WebUI QR login
connect flow obtained a token but only saved it to account.json (state
file). The subsequent post-connect enable step (set_channel_config_enabled)
read config.json, found no weixin section, and wrote back a default
config with token="" - silently losing the freshly obtained credential.

Add _persist_connect_credentials to _commit_account so the token and
base_url are written to config.json before the enable step runs. This
covers both the WebUI connect flow and the CLI QR login path, mirroring
the established Feishu save_registration_result pattern.
2026-08-13 11:04:16 +08:00
arcdrake22andXubin Ren 0c684c5a99 fix(gemini): preserve imported tool history across model switches
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-08-13 03:07:37 +09:00
yorkhellenandXubin Ren d3382d7e57 fix(exec): guard bare and named-user home paths
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-08-13 02:50:58 +09:00
Xubin Renandshixi-li 76f629e925 fix(web): keep credential URLs out of failure logs
Co-authored-by: shixi-li <40780706+shixi-li@users.noreply.github.com>
2026-08-13 02:26:22 +09:00
Xubin Ren 5f916bbd3a fix(web): keep credential redirects away from Jina 2026-08-13 02:26:22 +09:00
shixi-liandXubin Ren 31a71d6cd5 fix(web): keep credential-bearing URLs away from the remote Jina reader 2026-08-13 02:26:22 +09:00
Xubin Renandyu-xin-c 498070d036 test(docker): separate host bwrap requirements
Co-authored-by: yu-xin-c <175149126+yu-xin-c@users.noreply.github.com>
2026-08-13 02:13:51 +09:00
Xubin Renandyu-xin-c b7f0ae95a9 test(docker): run bwrap check as final user
Co-authored-by: yu-xin-c <175149126+yu-xin-c@users.noreply.github.com>
2026-08-13 02:13:51 +09:00
Xubin Renandyu-xin-c b571d3b9ff fix(docker): prevent privilege regain after bootstrap
Co-authored-by: yu-xin-c <175149126+yu-xin-c@users.noreply.github.com>
2026-08-13 02:13:51 +09:00
yu-xin-candXubin Ren 3741ecda0b fix(docker): restore capabilities for privilege drop 2026-08-13 02:13:51 +09:00
chengyongruandchengyongru edec29e997 feat(providers): support DeepSeek V4 Pro Responses 2026-08-13 01:02:41 +08:00
Xubin Renandsanthreal 01c7323d74 fix(exec): parse shell path boundaries safely
Co-authored-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-08-13 01:53:38 +09:00
Xubin Ren 001a7492c2 fix(exec): guard POSIX double-slash absolute paths 2026-08-13 01:53:38 +09:00
santhrealandXubin Ren 6fc0807fbf fix(tools): handle redirection and grouping delimiters in ExecTool path guard 2026-08-13 01:53:38 +09:00
Xubin Renandlmzopq cd7480945b fix(session): preserve history across storage relocation
Co-authored-by: lmzopq <1646888+lmzopq@users.noreply.github.com>
2026-08-13 01:41:10 +09:00
Xubin Ren 45245b5e55 docs(session): document external session storage 2026-08-13 01:41:10 +09:00
Xubin Ren d2cbe6536e fix(session): reject symlinked legacy session migration 2026-08-13 01:41:10 +09:00
李明振andXubin Ren b34f1bd0e8 fix(session): store session history outside the agent workspace
Session files lived under <workspace>/sessions/ (since #713), which is the
on-disk scope of the agent's filesystem tools. With restrict_to_workspace
enabled, an agent could read_file / list_dir every session transcript —
including other users' or channels' conversations — bypassing the scoped
sessions.py access layer entirely.

Move session storage to ~/.nanobot/sessions/<sha256-of-resolved-workspace>[:16]/,
outside the workspace. Per-workspace isolation (the goal of #713) is preserved
via a hash of the resolved workspace path, so different workspaces keep
independent session stores. A one-shot, idempotent migration moves legacy
in-workspace *.jsonl files into the new location at store init.

Scope note: this protects sessions whenever restrict_to_workspace=true. The
default restrict_to_workspace=false leaves read_file unrestricted in general
(not only sessions) and is a separate concern.

Refs #5278
2026-08-13 01:41:10 +09:00
Xubin Ren edaef4e4f5 fix(cli): isolate management subprocess environments 2026-08-12 21:09:29 +09:00
Xubin Ren 5fc8303f9e fix(webui): keep credentials out of service worker caches 2026-08-12 21:09:29 +09:00
chengyongruandchengyongru e455a2b7fa feat(webui): add MCP management dialog 2026-08-12 18:28:23 +08:00
chengyongruandGitHub 19997d20bb refactor: move MCP lifecycle out of AgentLoop (#5343) 2026-08-12 17:51:04 +08:00
chengyongruandchengyongru 686dd0603e fix(webui): restore session drag and review findings 2026-08-12 17:26:13 +08:00
chengyongruandGitHub 4b5319b760 feat(webui): add tabbed pane workbench (#5322) 2026-08-12 14:45:12 +08:00
chengyongruandchengyongru 1656664a47 test(exec): isolate Windows platform mock 2026-08-12 14:37:27 +08:00
chengyongruandchengyongru a6193932a0 fix(exec): clean up failed job assignment 2026-08-12 14:37:27 +08:00
chengyongruandchengyongru bcf5d8a6ed fix(exec): retain process trees after root exit 2026-08-12 14:37:27 +08:00
d64b84604c fix(exec): terminate one-shot process trees on cleanup
Run one-shot commands in their own process tree and terminate all
descendants after timeout, cancellation, or unexpected failures.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-08-12 14:37:27 +08:00
148 changed files with 12444 additions and 2045 deletions
+38
View File
@@ -189,6 +189,44 @@ jobs:
- name: Build image with default channel dependencies - name: Build image with default channel dependencies
run: docker build -t nanobot:test . run: docker build -t nanobot:test .
- name: Verify Docker Compose startup and privilege boundary
env:
HOME: ${{ runner.temp }}
run: |
docker compose run --rm --no-deps --build -T nanobot-cli status
docker compose run --rm --no-deps -T --entrypoint sh nanobot-cli -s <<'OUTER'
set -eu
field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
}
test "$(id -u)" = "0"
test "$(field NoNewPrivs)" = "1"
setpriv --reuid=nanobot --regid=nanobot --init-groups sh -s <<'INNER'
set -eu
field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
}
test "$(id -u)" = "1000"
test "$(field NoNewPrivs)" = "1"
for capability_set in CapInh CapPrm CapEff CapAmb; do
test "$(field "$capability_set")" = "0000000000000000"
done
INNER
OUTER
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml --profile cli \
config --format json > "${RUNNER_TEMP}/bwrap-compose.json"
python - <<'PY'
import json
import os
from pathlib import Path
config = json.loads(Path(os.environ["RUNNER_TEMP"], "bwrap-compose.json").read_text())
for service_name in ("nanobot-gateway", "nanobot-api", "nanobot-cli"):
service = config["services"][service_name]
assert {"CHOWN", "SETGID", "SETUID", "SYS_ADMIN"} <= set(service["cap_add"])
assert "no-new-privileges:true" in service["security_opt"]
PY
- name: Verify default WhatsApp dependencies - name: Verify default WhatsApp dependencies
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno" run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
+27
View File
@@ -6,6 +6,7 @@ import os
import ssl import ssl
import sys import sys
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path
import certifi import certifi
import pytest import pytest
@@ -22,6 +23,32 @@ def _isolate_nanobot_log_activation() -> Iterator[None]:
logger.enable("nanobot") logger.enable("nanobot")
@pytest.fixture(autouse=True)
def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Redirect session storage away from the real active config data directory.
Session storage lives under the active runtime data root (outside the workspace,
per ADR-0001), so without redirection tests would write into the real home.
"""
runtime_root = tmp_path.parent / f"{tmp_path.name}-runtime-root"
legacy_root = tmp_path.parent / f"{tmp_path.name}-legacy-sessions-root"
def runtime_subdir(name: str) -> Path:
path = runtime_root / name
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr(
"nanobot.session.manager.get_runtime_subdir",
runtime_subdir,
)
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: legacy_root,
)
yield
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]: def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client. """Avoid reparsing certifi's CA bundle for every offline HTTP client.
+9
View File
@@ -8,6 +8,15 @@ x-common-config: &common-config
- ~/.nanobot:/home/nanobot/.nanobot - ~/.nanobot:/home/nanobot/.nanobot
cap_drop: cap_drop:
- ALL - ALL
# Entrypoint uses these to fix bind-mount ownership and drop to the nanobot user.
cap_add:
- CHOWN
- SETGID
- SETUID
# Prevent the non-root process from regaining capabilities through setuid
# binaries or file capabilities left inside the container image.
security_opt:
- no-new-privileges:true
services: services:
nanobot-gateway: nanobot-gateway:
+10 -3
View File
@@ -51,6 +51,13 @@ Main files:
- feeds tool results back into the model; - feeds tool results back into the model;
- stops when a final answer is produced or runtime limits are hit. - stops when a final answer is produced or runtime limits are hit.
MCP connections are application-owned infrastructure. Composition roots create
an `MCPProvider`, share its `ToolRegistry` with `AgentLoop`, await `connect()`
before use, and guarantee `aclose()` during shutdown; the loop does not manage
that lifecycle. `AgentLoop.from_config()` therefore requires a caller-owned
`ToolRegistry`; callers using MCP share it with their application-owned
`MCPProvider`.
Keep this split in mind when debugging. If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in `agent/loop.py`. If it is about provider calls, tool calls, streaming, or iteration limits, start in `agent/runner.py`. Keep this split in mind when debugging. If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in `agent/loop.py`. If it is about provider calls, tool calls, streaming, or iteration limits, start in `agent/runner.py`.
## Providers ## Providers
@@ -142,7 +149,7 @@ Defaults:
|---|---| |---|---|
| Config | `~/.nanobot/config.json` | | Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` | | Workspace | `~/.nanobot/workspace/` |
| Sessions | `<workspace>/sessions/*.jsonl` | | Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
| Memory | `<workspace>/memory/` | | Memory | `<workspace>/memory/` |
| Cron store | `<workspace>/cron/jobs.json` | | Cron store | `<workspace>/cron/jobs.json` |
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` | | WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
@@ -157,7 +164,7 @@ a WebUI chat may select a separate project:
| Concern | Path owner | | Concern | Path owner |
|---|---| |---|---|
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace | | Session namespace, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace | | Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
| Workspace access mode and project metadata | Session workspace scope | | Workspace access mode and project metadata | Session workspace scope |
@@ -173,7 +180,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
| Store | File area | | Store | File area |
|---|---| |---|---|
| Session JSONL files | `<workspace>/sessions/` | | Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
| Long-term memory | `<workspace>/memory/MEMORY.md` | | Long-term memory | `<workspace>/memory/MEMORY.md` |
| Consolidation source history | `<workspace>/memory/history.jsonl` | | Consolidation source history | `<workspace>/memory/history.jsonl` |
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` | | Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
+18
View File
@@ -94,6 +94,24 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown | | `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
| `nanobot agent --logs` | Show runtime logs while chatting | | `nanobot agent --logs` | Show runtime logs while chatting |
## Session Storage and Rollback
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
agent-readable workspace. On the first upgraded start, nanobot safely migrates existing
`<workspace>/sessions/*.jsonl` files after verifying an atomic copy. Stop every old nanobot
process that uses the workspace before upgrading; old and new binaries must not write the
same session concurrently.
To prepare a downgrade, stop nanobot and copy the current sessions back to the path understood
by older releases:
```bash
nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bot-a/workspace
```
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending. In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
+9 -2
View File
@@ -26,7 +26,8 @@ The default instance lives under `~/.nanobot/`:
| Path | Meaning | | Path | Meaning |
|---|---| |---|---|
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options | | `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
| `~/.nanobot/workspace/` | Agent workspace: memory, sessions, heartbeat tasks, cron jobs, skills, and generated artifacts | | `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
| `~/.nanobot/sessions/<workspace-id>/` | Session history stored outside the agent-accessible workspace; the opaque ID follows workspace moves |
You can override both with command flags: You can override both with command flags:
@@ -125,11 +126,17 @@ nanobot uses two related stores:
| Store | Location | Purpose | | Store | Location | Purpose |
|---|---|---| |---|---|---|
| Sessions | `<workspace>/sessions/*.jsonl` | Recent conversation turns replayed into context | | Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` | Recent conversation turns replayed into context |
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history | | Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay. Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
The configured workspace contains a `.nanobot/workspace-id` file. It contains only an
opaque random identifier—never conversation content or credentials. Keep it with workspace
backups: it lets nanobot find the same external session namespace after the workspace is
renamed, moved, or restored. A live copy opened alongside the original receives a new ID so
the two workspaces do not share conversations accidentally.
See [`memory.md`](./memory.md) for the detailed design. See [`memory.md`](./memory.md) for the detailed design.
## Apps and Agent Plugins ## Apps and Agent Plugins
+14 -6
View File
@@ -360,7 +360,7 @@ request, while other tools such as `web_fetch` remain available.
<details> <details>
<summary><b>DeepSeek native web search</b></summary> <summary><b>DeepSeek native web search</b></summary>
DeepSeek V4 Flash uses DeepSeek's native Responses API. Its provider-hosted web search is DeepSeek V4 Flash and Pro use DeepSeek's native Responses API. Their provider-hosted web search is
enabled by default because it does not require a separate paid add-on. Turn it off from the enabled by default because it does not require a separate paid add-on. Turn it off from the
WebUI provider settings, or with: WebUI provider settings, or with:
@@ -377,9 +377,9 @@ WebUI provider settings, or with:
} }
``` ```
The switch applies to `deepseek-v4-flash`; DeepSeek models that remain on Chat Completions The switch applies to `deepseek-v4-flash` and `deepseek-v4-pro`; DeepSeek models that remain on
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and Chat Completions cannot use this Responses tool. Native search calls appear in the WebUI activity
their opaque output items are preserved for multi-turn Responses state replay. stream, and their opaque output items are preserved for multi-turn Responses state replay.
</details> </details>
@@ -391,7 +391,7 @@ Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically. compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models. nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required. headroom; no provider configuration is required.
@@ -1921,6 +1921,14 @@ Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_K
nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails. nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails.
> [!NOTE]
> Using the remote reader means the fetched URL itself is disclosed to the
> third-party service. URLs that visibly carry credentials (userinfo, signed-URL
> or token-style query parameters) are detected and fetched locally instead, but
> secrets embedded in a URL's *path* (for example bot-token or webhook-style
> URLs) cannot be reliably detected. Set `useJinaReader: false` if fetched URLs
> must never leave the machine.
If you want to always use the local conversion, you can force it using: If you want to always use the local conversion, you can force it using:
```json ```json
@@ -2095,7 +2103,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces. **Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities except the `CHOWN`, `SETGID`, and `SETUID` capabilities required by the root entrypoint to initialize bind-mount ownership and become UID 1000. It enables `no-new-privileges` so the final non-root process cannot regain those bootstrap capabilities, and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces. The host must also allow unprivileged user namespaces; the override cannot bypass a host-level namespace restriction.
## Pairing ## Pairing
+15 -6
View File
@@ -11,7 +11,7 @@ Check these once before Render, Docker, systemd, or LaunchAgent:
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run | | `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer | | `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable | | Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there | | The active config directory (including `sessions/`) and workspace are persistent | Sessions follow `--config`; memory, generated artifacts, and the workspace identity marker follow the workspace |
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot | | Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` | | Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup | | Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
@@ -160,8 +160,11 @@ docker compose logs -f nanobot-gateway # view logs
docker compose down # stop docker compose down # stop
``` ```
The default Compose file drops all Linux capabilities and keeps Docker's default The default Compose file drops all Linux capabilities except `CHOWN`, `SETUID`, and
AppArmor/seccomp profiles enabled. If you explicitly set `SETGID`, which the root entrypoint needs to fix bind-mount ownership and become UID
1000. It also enables `no-new-privileges`, so the non-root process cannot regain those
bootstrap capabilities through setuid binaries or file capabilities. Docker's default
AppArmor/seccomp profiles remain enabled. If you explicitly set
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap `"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
override file when starting containers: override file when starting containers:
@@ -170,8 +173,10 @@ docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-g
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!" docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
``` ```
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for The override adds `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for the
the container so bubblewrap can create its nested namespaces. Use it only when the container so bubblewrap can create its nested namespaces. It preserves
`no-new-privileges`. The host must also allow unprivileged user namespaces; the
override cannot bypass a host-level namespace restriction. Use it only when the
bwrap sandbox is enabled. bwrap sandbox is enabled.
### Docker ### Docker
@@ -197,6 +202,8 @@ vim ~/.nanobot/config.json
# health endpoint on 18790. # health endpoint on 18790.
docker run \ docker run \
--cap-drop ALL \ --cap-drop ALL \
--cap-add CHOWN --cap-add SETGID --cap-add SETUID \
--security-opt no-new-privileges:true \
-v ~/.nanobot:/home/nanobot/.nanobot \ -v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \ -p 18790:18790 -p 8765:8765 \
nanobot gateway nanobot gateway
@@ -205,7 +212,9 @@ docker run \
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with # bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`. # `clone3: Operation not permitted`.
docker run \ docker run \
--cap-drop ALL --cap-add SYS_ADMIN \ --cap-drop ALL \
--cap-add CHOWN --cap-add SETGID --cap-add SETUID --cap-add SYS_ADMIN \
--security-opt no-new-privileges:true \
--security-opt apparmor=unconfined \ --security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \ --security-opt seccomp=unconfined \
-v ~/.nanobot:/home/nanobot/.nanobot \ -v ~/.nanobot:/home/nanobot/.nanobot \
+4
View File
@@ -81,6 +81,10 @@ in the WebUI or logs.
- Web fetch and HTTP MCP share an SSRF guard. - Web fetch and HTTP MCP share an SSRF guard.
- Private, loopback, link-local, and cloud metadata addresses are blocked by - Private, loopback, link-local, and cloud metadata addresses are blocked by
default. default.
- With `useJinaReader` enabled (the default), fetched URLs are disclosed to the
remote reader service. Credential-bearing URLs (userinfo or token/signature
query parameters) are fetched locally instead; path-embedded secrets cannot
be detected, so disable the remote reader when URLs must stay local.
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs. - Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
- Do not give public chat users unrestricted web and shell access without - Do not give public chat users unrestricted web and shell access without
review. review.
+2 -2
View File
@@ -47,8 +47,8 @@ nanobot gateway logs
- Docker Compose is the most repeatable Linux container path. - Docker Compose is the most repeatable Linux container path.
- systemd user services are useful for Linux user-level gateway deployments. - systemd user services are useful for Linux user-level gateway deployments.
- macOS LaunchAgent keeps the gateway alive after login. - macOS LaunchAgent keeps the gateway alive after login.
- Persist config, workspace, sessions, memory files, channel login state, and - Persist the active config directory's `sessions/` folder together with the workspace
generated artifacts. (including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
- Restart the gateway after editing `config.json`. - Restart the gateway after editing `config.json`.
## Security notes ## Security notes
+2 -1
View File
@@ -58,6 +58,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|-----------|---------------|---------| |-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` | | **Config** | `--config` path | `~/.nanobot-A/config.json` |
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` | | **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
| **Sessions** | config directory + workspace ID | `~/.nanobot-A/sessions/<workspace-id>/` |
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` | | **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` | | **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
@@ -126,6 +127,6 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
## Notes ## Notes
- Each instance must use a different port if they run at the same time - Each instance must use a different port if they run at the same time
- Use a different workspace per instance if you want isolated memory, sessions, and skills - Session data follows the active config directory; use a different workspace per instance to isolate memory, skills, and the stable session namespace ID
- `--workspace` overrides the workspace defined in the config file - `--workspace` overrides the workspace defined in the config file
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory - Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
+1 -1
View File
@@ -287,7 +287,7 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`. `providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it. DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` and `deepseek-v4-pro` automatically use DeepSeek's native Responses API. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
### Custom OpenAI-Compatible Endpoint ### Custom OpenAI-Compatible Endpoint
+2 -1
View File
@@ -48,7 +48,8 @@ The WebUI launcher creates or updates:
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings | | `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files | | `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
If the installer did not open the browser, run: If the installer did not open the browser, run:
+2 -1
View File
@@ -319,7 +319,8 @@ See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|---|---| |---|---|
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. | | Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. | | Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
| Old sessions appear after moving config | Session files are stored under `<workspace>/sessions/`; verify the workspace path. | | Sessions disappear after changing `--config` | Sessions follow the config directory at `<config-dir>/sessions/<workspace-id>/`; use the original config path or copy that `sessions/` directory into the new config directory while nanobot is stopped. |
| Sessions disappear after moving a workspace | Keep the workspace's `.nanobot/workspace-id` file with the move or backup. If it was lost, restore that marker from backup before starting nanobot. |
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. | | You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
## Collect Useful Evidence ## Collect Useful Evidence
+1 -19
View File
@@ -42,29 +42,11 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
) )
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
return mcp_tools.runtime_status(state)
async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool: async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD: if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
await state.discard_session(msg.session_key) await state.discard_session(msg.session_key)
return True return True
for handler in ( return await image_generation_tools.handle_runtime_control(state, msg, tools)
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
):
if await handler(state, msg, tools):
return True
return False
class ContextBuilder: class ContextBuilder:
+24 -33
View File
@@ -95,11 +95,9 @@ from nanobot.utils.runtime import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import ( from nanobot.config.schema import (
ChannelsConfig, ChannelsConfig,
Config, Config,
MCPServerConfig,
ProviderConfig, ProviderConfig,
ToolsConfig, ToolsConfig,
) )
@@ -271,7 +269,7 @@ class AgentLoop:
cron_service: CronService | None = None, cron_service: CronService | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
mcp_servers: dict[str, MCPServerConfig] | None = None, tool_registry: ToolRegistry | None = None,
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
timezone: str | None = None, timezone: str | None = None,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
@@ -379,7 +377,7 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive) self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
self.tools = ToolRegistry() self.tools = tool_registry if tool_registry is not None else ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is # One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars. # shared by this loop, so tools resolve the active state via contextvars.
self._file_state_store = FileStateStore() self._file_state_store = FileStateStore()
@@ -399,15 +397,11 @@ class AgentLoop:
) )
self._unified_session = unified_session self._unified_session = unified_session
self._running = False self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = [] self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {} self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._discarding_sessions: set[str] = set() self._discarding_sessions: set[str] = set()
self._background_tasks: set[asyncio.Task[Any]] = set() self._background_tasks: set[asyncio.Task[Any]] = set()
self._close_mcp_lock = asyncio.Lock() self._close_lock = asyncio.Lock()
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary() weakref.WeakValueDictionary()
) )
@@ -464,10 +458,15 @@ class AgentLoop:
cls, cls,
config: Config, config: Config,
bus: MessageBus | None = None, bus: MessageBus | None = None,
*,
tool_registry: ToolRegistry,
**extra: Any, **extra: Any,
) -> AgentLoop: ) -> AgentLoop:
"""Create an AgentLoop from config with the common parameter set. """Create an AgentLoop from config with the common parameter set.
The tool registry is caller-owned so application composition can share
it with infrastructure such as an ``MCPProvider``.
Extra keyword arguments are forwarded to ``AgentLoop.__init__``, Extra keyword arguments are forwarded to ``AgentLoop.__init__``,
allowing callers to override or extend the standard config-derived allowing callers to override or extend the standard config-derived
parameters (e.g. ``cron_service``, ``session_manager``). parameters (e.g. ``cron_service``, ``session_manager``).
@@ -477,6 +476,12 @@ class AgentLoop:
if bus is None: if bus is None:
bus = MessageBus() bus = MessageBus()
defaults = config.agents.defaults defaults = config.agents.defaults
if "session_manager" not in extra:
data_dir = config.runtime_data_dir
extra["session_manager"] = SessionManager(
config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
provider = extra.pop("provider", None) or make_provider(config) provider = extra.pop("provider", None) or make_provider(config)
resolved = config.resolve_preset() resolved = config.resolve_preset()
model = extra.pop("model", None) or resolved.model model = extra.pop("model", None) or resolved.model
@@ -486,8 +491,6 @@ class AgentLoop:
config, config,
provider_snapshot_loader, provider_snapshot_loader,
) )
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls( return cls(
bus=bus, bus=bus,
provider=provider, provider=provider,
@@ -502,7 +505,6 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode, provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length, tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels, channels_config=config.channels,
timezone=defaults.timezone, timezone=defaults.timezone,
unified_session=defaults.unified_session, unified_session=defaults.unified_session,
@@ -517,6 +519,7 @@ class AgentLoop:
restart_mode=config.gateway.restart_mode, restart_mode=config.gateway.restart_mode,
provider_snapshot_loader=provider_snapshot_loader, provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader,
tool_registry=tool_registry,
**extra, **extra,
) )
@@ -643,14 +646,6 @@ class AgentLoop:
logger.info("Registered {} tools: {}", len(registered), registered) logger.info("Registered {} tools: {}", len(registered), registered)
async def _connect_mcp(self) -> None:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return connection state learned from real MCP runtime attempts."""
return agent_context.mcp_runtime_status(self)
def register_runtime_context_provider( def register_runtime_context_provider(
self, self,
provider: RuntimeContextProvider, provider: RuntimeContextProvider,
@@ -1162,7 +1157,6 @@ class AgentLoop:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop.""" """Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True self._running = True
try: try:
await self._connect_mcp()
logger.info("Agent loop started") logger.info("Agent loop started")
while self._running: while self._running:
@@ -1253,8 +1247,7 @@ class AgentLoop:
active_tasks.add(task) active_tasks.add(task)
task.add_done_callback(active_tasks.discard) task.add_done_callback(active_tasks.discard)
finally: finally:
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them. await self.aclose()
await self.close_mcp()
async def _dispatch(self, msg: InboundMessage) -> None: async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent.""" """Process a message: per-session serial, cross-session concurrent."""
@@ -1372,24 +1365,24 @@ class AgentLoop:
await delivery.idle() await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key) await self._publish_next_deferred_automation_turn(session_key)
async def close_mcp(self) -> None: async def aclose(self) -> None:
"""Stop active work, then close exec, subagent, and MCP resources. """Stop active work, then close resources owned by the agent loop.
Resource teardown must still run if cancellation interrupts task draining. Resource teardown must still run if cancellation interrupts task draining.
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
phase in ``finally`` prevents a timed-out background task from leaving phase in ``finally`` prevents a timed-out background task from leaving
subprocess transports alive after the event loop closes. subprocess transports alive after the event loop closes.
""" """
# The agent loop closes itself from ``run()`` while gateway shutdown also # The loop closes itself from ``run()`` while application shutdown also
# performs a guaranteed final close. Serialize those owners so they cannot # performs a guaranteed final close. Serialize those owners so they cannot
# tear down the same subprocess transports concurrently. # tear down the same resources concurrently.
close_lock = getattr(self, "_close_mcp_lock", None) close_lock = getattr(self, "_close_lock", None)
if close_lock is None: if close_lock is None:
close_lock = self._close_mcp_lock = asyncio.Lock() close_lock = self._close_lock = asyncio.Lock()
async with close_lock: async with close_lock:
await self._close_mcp_unlocked() await self._aclose_unlocked()
async def _close_mcp_unlocked(self) -> None: async def _aclose_unlocked(self) -> None:
errors: list[BaseException] = [] errors: list[BaseException] = []
active_task_groups = getattr(self, "_active_tasks", {}) active_task_groups = getattr(self, "_active_tasks", {})
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks}) active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
@@ -1412,7 +1405,6 @@ class AgentLoop:
cleanup_steps = ( cleanup_steps = (
self.subagents.close, self.subagents.close,
self._exec_session_manager.close_all, self._exec_session_manager.close_all,
lambda: agent_context.close_mcp(self),
) )
for cleanup in cleanup_steps: for cleanup in cleanup_steps:
try: try:
@@ -2301,7 +2293,6 @@ class AgentLoop:
"""Process an external message directly and return the outbound payload.""" """Process an external message directly and return the outbound payload."""
if channel == "system": if channel == "system":
raise ValueError("channel 'system' is reserved for internal messages") raise ValueError("channel 'system' is reserved for internal messages")
await self._connect_mcp()
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
if not persist_user_message: if not persist_user_message:
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
+218
View File
@@ -0,0 +1,218 @@
"""Windows Job Object ownership for subprocess trees."""
from __future__ import annotations
import ctypes
from ctypes import wintypes
_CREATE_SUSPENDED = 0x00000004
_PROCESS_SET_QUOTA = 0x0100
_PROCESS_TERMINATE = 0x0001
_TH32CS_SNAPTHREAD = 0x00000004
_THREAD_SUSPEND_RESUME = 0x0002
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
class _IoCounters(ctypes.Structure):
_fields_ = [
("ReadOperationCount", ctypes.c_ulonglong),
("WriteOperationCount", ctypes.c_ulonglong),
("OtherOperationCount", ctypes.c_ulonglong),
("ReadTransferCount", ctypes.c_ulonglong),
("WriteTransferCount", ctypes.c_ulonglong),
("OtherTransferCount", ctypes.c_ulonglong),
]
class _BasicLimitInformation(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class _ExtendedLimitInformation(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", _BasicLimitInformation),
("IoInfo", _IoCounters),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
class _ThreadEntry32(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ThreadID", wintypes.DWORD),
("th32OwnerProcessID", wintypes.DWORD),
("tpBasePri", wintypes.LONG),
("tpDeltaPri", wintypes.LONG),
("dwFlags", wintypes.DWORD),
]
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
_kernel32.SetInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
ctypes.c_void_p,
wintypes.DWORD,
]
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
_kernel32.OpenProcess.restype = wintypes.HANDLE
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
_kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
_kernel32.TerminateProcess.restype = wintypes.BOOL
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
_kernel32.TerminateJobObject.restype = wintypes.BOOL
_kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
_kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
_kernel32.Thread32First.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
_kernel32.Thread32First.restype = wintypes.BOOL
_kernel32.Thread32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
_kernel32.Thread32Next.restype = wintypes.BOOL
_kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
_kernel32.OpenThread.restype = wintypes.HANDLE
_kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
_kernel32.ResumeThread.restype = wintypes.DWORD
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
_kernel32.CloseHandle.restype = wintypes.BOOL
def _win_error(operation: str) -> OSError:
code = ctypes.get_last_error()
return OSError(code, f"{operation} failed (Windows error {code})")
def _close_handle(handle: int | None) -> None:
if handle:
_kernel32.CloseHandle(handle)
def _set_kill_on_close(handle: int, enabled: bool) -> None:
info = _ExtendedLimitInformation()
if enabled:
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not _kernel32.SetInformationJobObject(
handle,
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
ctypes.byref(info),
ctypes.sizeof(info),
):
raise _win_error("SetInformationJobObject")
def _resume_primary_thread(pid: int) -> None:
snapshot = _kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPTHREAD, 0)
if snapshot == _INVALID_HANDLE_VALUE:
raise _win_error("CreateToolhelp32Snapshot")
try:
entry = _ThreadEntry32()
entry.dwSize = ctypes.sizeof(entry)
found = _kernel32.Thread32First(snapshot, ctypes.byref(entry))
while found:
if entry.th32OwnerProcessID == pid:
thread = _kernel32.OpenThread(
_THREAD_SUSPEND_RESUME,
False,
entry.th32ThreadID,
)
if not thread:
raise _win_error("OpenThread")
try:
if _kernel32.ResumeThread(thread) == 0xFFFFFFFF:
raise _win_error("ResumeThread")
return
finally:
_close_handle(thread)
found = _kernel32.Thread32Next(snapshot, ctypes.byref(entry))
raise RuntimeError(f"suspended process {pid} has no resumable thread")
finally:
_close_handle(snapshot)
class WindowsJob:
"""Own a process tree even after its root process exits."""
creation_flags = _CREATE_SUSPENDED
def __init__(self, handle: int) -> None:
self._handle: int | None = handle
@classmethod
def create(cls) -> WindowsJob:
handle = _kernel32.CreateJobObjectW(None, None)
if not handle:
raise _win_error("CreateJobObjectW")
try:
_set_kill_on_close(handle, True)
except Exception:
_close_handle(handle)
raise
return cls(handle)
def assign_and_resume(self, pid: int) -> None:
"""Atomically establish tree ownership before the root can spawn."""
if self._handle is None:
raise RuntimeError("Windows job is already closed")
process = _kernel32.OpenProcess(
_PROCESS_SET_QUOTA | _PROCESS_TERMINATE,
False,
pid,
)
if not process:
error = _win_error("OpenProcess")
self.close()
raise error
if not _kernel32.AssignProcessToJobObject(self._handle, process):
error = _win_error("AssignProcessToJobObject")
_kernel32.TerminateProcess(process, 1)
_close_handle(process)
self.close()
raise error
try:
_resume_primary_thread(pid)
except Exception:
self.terminate()
raise
finally:
_close_handle(process)
def release(self) -> None:
"""Release ownership after successful output collection."""
if self._handle is None:
return
_set_kill_on_close(self._handle, False)
self.close()
def terminate(self) -> None:
"""Terminate every process in the job and close its handle."""
if self._handle is None:
return
try:
_kernel32.TerminateJobObject(self._handle, 1)
finally:
self.close()
def close(self) -> None:
handle = self._handle
self._handle = None
_close_handle(handle)
+5 -1
View File
@@ -209,7 +209,11 @@ class _ExecSession:
timeout=2.0, timeout=2.0,
) )
# Safety-net reap after normal exit. # Safety-net reap after normal exit.
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage] from nanobot.agent.tools.shell import ( # pyright: ignore[reportPrivateUsage]
ExecTool,
_reap_pid, # pyright: ignore[reportPrivateUsage]
)
ExecTool._release_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage] _reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
elif yield_time_ms > 0: elif yield_time_ms > 0:
await self._wait_for_buffered_output() await self._wait_for_buffered_output()
+250 -269
View File
@@ -1,4 +1,6 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" """MCP client and dynamic tool-provider lifecycle."""
from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
@@ -7,23 +9,15 @@ import os
import re import re
import shutil import shutil
import urllib.parse import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
from contextlib import AsyncExitStack, suppress from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
from weakref import WeakKeyDictionary
import httpx import httpx
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import ( from nanobot.security.network import (
PinnedDNSAsyncTransport, PinnedDNSAsyncTransport,
env_proxy_applies_to_url, env_proxy_applies_to_url,
@@ -39,7 +33,7 @@ if TYPE_CHECKING:
from mcp.types import Tool as MCPToolDefinition from mcp.types import Tool as MCPToolDefinition
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import Config, MCPServerConfig
# Transient connection errors that warrant a single retry. # Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network # These typically happen when an MCP server restarts or a network
@@ -60,18 +54,37 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.). # Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs. # Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
_SANITIZE_RE = re.compile(r"_+") _SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]] _ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
MCPServerLoader = Callable[[], Mapping[str, "MCPServerConfig"]]
MCPRuntimeStatus = Literal["connecting", "connected", "failed"] MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
("connecting", "connected", "failed")
)
class MCPConnection(Protocol): class MCPConnection(Protocol):
async def aclose(self) -> None: ... async def aclose(self) -> None: ...
async def _close_mcp_connection(name: str, connection: MCPConnection) -> None:
try:
await connection.aclose()
except asyncio.CancelledError:
if task_is_cancelling():
raise
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
async def _close_mcp_connections(connections: Mapping[str, MCPConnection]) -> None:
cancellation: asyncio.CancelledError | None = None
for name, connection in connections.items():
try:
await _close_mcp_connection(name, connection)
except asyncio.CancelledError as exc:
cancellation = cancellation or exc
if cancellation is not None:
raise cancellation
class _OwnedMCPConnection: class _OwnedMCPConnection:
"""Close an MCP transport from the task that originally opened it.""" """Close an MCP transport from the task that originally opened it."""
@@ -492,11 +505,11 @@ class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session.""" """Common reconnect handling for wrappers bound to one MCP server session."""
_plugin_discoverable = False _plugin_discoverable = False
_session: "ClientSession" _session: ClientSession
_server_name: str _server_name: str
_name: str _name: str
def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None: def _set_mcp_connection(self, session: ClientSession, server_name: str) -> None:
self._session = session self._session = session
self._server_name = server_name self._server_name = server_name
self._reconnect: _ReconnectCallback | None = None self._reconnect: _ReconnectCallback | None = None
@@ -586,9 +599,9 @@ class MCPToolWrapper(_MCPWrapperBase):
def __init__( def __init__(
self, self,
session: "ClientSession", session: ClientSession,
server_name: str, server_name: str,
tool_def: "MCPToolDefinition", tool_def: MCPToolDefinition,
tool_timeout: int = 30, tool_timeout: int = 30,
): ):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
@@ -748,9 +761,9 @@ class MCPResourceWrapper(_MCPWrapperBase):
def __init__( def __init__(
self, self,
session: "ClientSession", session: ClientSession,
server_name: str, server_name: str,
resource_def: "Resource", resource_def: Resource,
resource_timeout: int = 30, resource_timeout: int = 30,
): ):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
@@ -852,9 +865,9 @@ class MCPPromptWrapper(_MCPWrapperBase):
def __init__( def __init__(
self, self,
session: "ClientSession", session: ClientSession,
server_name: str, server_name: str,
prompt_def: "Prompt", prompt_def: Prompt,
prompt_timeout: int = 30, prompt_timeout: int = 30,
): ):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
@@ -985,10 +998,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers( async def connect_mcp_servers(
mcp_servers: "dict[str, MCPServerConfig]", mcp_servers: dict[str, MCPServerConfig],
registry: ToolRegistry, registry: ToolRegistry,
*, *,
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None, oauth_handlers: Mapping[str, MCPOAuthHandlers] | None = None,
) -> dict[str, MCPConnection]: ) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts. """Connect to configured MCP servers and register their tools, resources, prompts.
@@ -1002,7 +1015,7 @@ async def connect_mcp_servers(
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
async def open_single_server( async def open_single_server(
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack name: str, cfg: MCPServerConfig, server_stack: AsyncExitStack
) -> bool: ) -> bool:
try: try:
transport_type = cfg.type transport_type = cfg.type
@@ -1244,7 +1257,7 @@ async def connect_mcp_servers(
return False return False
async def connect_single_server( async def connect_single_server(
name: str, cfg: "MCPServerConfig" name: str, cfg: MCPServerConfig
) -> tuple[str, MCPConnection | None]: ) -> tuple[str, MCPConnection | None]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
ready: asyncio.Future[bool] = loop.create_future() ready: asyncio.Future[bool] = loop.create_future()
@@ -1282,8 +1295,11 @@ async def connect_mcp_servers(
return name, connection return name, connection
server_stacks: dict[str, MCPConnection] = {} server_stacks: dict[str, MCPConnection] = {}
attempted_names: list[str] = []
try:
for name, cfg in mcp_servers.items(): for name, cfg in mcp_servers.items():
attempted_names.append(name)
try: try:
result = await connect_single_server(name, cfg) result = await connect_single_server(name, cfg)
except Exception as e: except Exception as e:
@@ -1291,6 +1307,17 @@ async def connect_mcp_servers(
continue continue
if result[1] is not None: if result[1] is not None:
server_stacks[result[0]] = result[1] server_stacks[result[0]] = result[1]
except BaseException:
# Callers can bound readiness/reload with a timeout. If cancellation
# interrupts a later server, ownership of earlier connections has not
# transferred yet, so roll the whole batch back before propagating it.
for name in attempted_names:
_unregister_server_tools(registry, name)
try:
await _close_mcp_connections(server_stacks)
except BaseException as cleanup_exc:
logger.debug("MCP batch rollback cleanup error (can be ignored): {}", cleanup_exc)
raise
return server_stacks return server_stacks
@@ -1301,69 +1328,101 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {} return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def _runtime_status_store( def _configured_servers(config: Config) -> dict[str, MCPServerConfig]:
state: Any, from nanobot.agent.plugins import agent_plugin_mcp_servers
return agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
def _load_current_servers() -> dict[str, MCPServerConfig]:
from nanobot.config.loader import load_config, resolve_config_env_vars
return _configured_servers(resolve_config_env_vars(load_config()))
class MCPProvider:
"""Own configured MCP connections and their dynamic tool registrations."""
def __init__(
self,
servers: Mapping[str, MCPServerConfig],
registry: ToolRegistry,
*, *,
create: bool = False, server_loader: MCPServerLoader | None = None,
) -> dict[str, MCPRuntimeStatus] | None: ) -> None:
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None) self._servers = dict(servers)
if isinstance(raw_statuses, dict): self._registry = registry
return cast(dict[str, MCPRuntimeStatus], raw_statuses) self._server_loader = server_loader or _load_current_servers
if not create: self._connections: dict[str, MCPConnection] = {}
return None self._runtime_statuses: dict[str, MCPRuntimeStatus] = {}
statuses: dict[str, MCPRuntimeStatus] = {} self._lock = asyncio.Lock()
state._mcp_runtime_statuses = statuses self._closing = False
return statuses
@classmethod
def from_config(
cls,
config: Config,
registry: ToolRegistry,
*,
server_loader: MCPServerLoader | None = None,
) -> MCPProvider:
return cls(
_configured_servers(config),
registry,
server_loader=server_loader,
)
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]: @property
"""Return the latest connection-attempt result for configured MCP servers.""" def configured_server_names(self) -> set[str]:
statuses = _runtime_status_store(state) return set(self._servers)
raw_configured: object = getattr(state, "_mcp_servers", None)
if statuses is None or not isinstance(raw_configured, dict): @property
return {} def connected_server_names(self) -> set[str]:
configured = cast(dict[str, Any], raw_configured) return set(self._connections)
def runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return the latest connection-attempt result for configured servers."""
return { return {
name: status name: status
for name, status in statuses.items() for name, status in self._runtime_statuses.items()
if name in configured and status in _MCP_RUNTIME_STATUSES if name in self._servers
} }
def _set_runtime_status(
def _set_runtime_status( self,
state: Any, server_names: Iterable[str],
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
status: MCPRuntimeStatus, status: MCPRuntimeStatus,
) -> None: ) -> None:
statuses = _runtime_status_store(state, create=True)
assert statuses is not None
for name in server_names: for name in server_names:
statuses[name] = status self._runtime_statuses[name] = status
def _record_connection_result(
def _record_connection_result( self,
state: Any, attempted: Iterable[str],
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...], connected: Iterable[str],
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...], ) -> None:
) -> None:
attempted_names = set(attempted) attempted_names = set(attempted)
connected_names = set(connected) connected_names = set(connected)
_set_runtime_status(state, connected_names, "connected") self._set_runtime_status(connected_names, "connected")
_set_runtime_status(state, attempted_names - connected_names, "failed") self._set_runtime_status(attempted_names - connected_names, "failed")
async def connect(self) -> None:
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None: """Connect configured servers that are not currently live."""
"""Connect configured MCP servers that are not currently live.""" async with self._lock:
async with _reload_lock(state): if self._closing:
if getattr(state, "_mcp_closing", False):
return return
configured_missing = { configured_missing = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks name: cfg
for name, cfg in self._servers.items()
if name not in self._connections
} }
oauth_servers = { oauth_servers = {
name: cfg name: cfg
for name, cfg in configured_missing.items() for name, cfg in configured_missing.items()
if getattr(cfg, "auth", None) == "oauth" if cfg.auth == "oauth"
} }
authorization_pending: set[str] = set() authorization_pending: set[str] = set()
if oauth_servers: if oauth_servers:
@@ -1374,62 +1433,53 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
for name, cfg in oauth_servers.items() for name, cfg in oauth_servers.items()
if not mcp_oauth_has_credentials(name, cfg.url) if not mcp_oauth_has_credentials(name, cfg.url)
} }
statuses = _runtime_status_store(state)
if statuses is not None:
for name in authorization_pending: for name in authorization_pending:
statuses.pop(name, None) self._runtime_statuses.pop(name, None)
missing_servers = { missing_servers = {
name: cfg name: cfg
for name, cfg in configured_missing.items() for name, cfg in configured_missing.items()
if name not in authorization_pending if name not in authorization_pending
} }
if state._mcp_connecting or not missing_servers: if not missing_servers:
return return
state._mcp_connecting = True self._set_runtime_status(missing_servers, "connecting")
_set_runtime_status(state, missing_servers, "connecting")
try: try:
connected = await connect_mcp_servers(missing_servers, registry) connected = await connect_mcp_servers(missing_servers, self._registry)
if getattr(state, "_mcp_closing", False): if self._closing:
for connection in connected.values(): await _close_mcp_connections(connected)
await connection.aclose()
return return
state._mcp_stacks.update(connected) self._connections.update(connected)
_record_connection_result(state, missing_servers, connected) self._record_connection_result(missing_servers, connected)
_attach_reconnect_handlers(state, registry, connected) self._attach_reconnect_handlers(connected)
if connected: if connected:
logger.info("MCP connected servers: {}", sorted(connected)) logger.info("MCP connected servers: {}", sorted(connected))
else: else:
logger.warning("No MCP servers connected successfully (will retry next message)") logger.warning(
"No MCP servers connected successfully "
"(will retry on the next readiness check)"
)
except asyncio.CancelledError: except asyncio.CancelledError:
self._set_runtime_status(missing_servers, "failed")
if task_is_cancelling(): if task_is_cancelling():
raise raise
_set_runtime_status(state, missing_servers, "failed") logger.warning(
logger.warning("MCP connection cancelled (will retry next message)") "MCP connection cancelled (will retry on the next readiness check)"
except BaseException as e:
_set_runtime_status(state, missing_servers, "failed")
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
finally:
state._mcp_connecting = False
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"""Reconcile live MCP connections with the current config file."""
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return {
"ok": False,
"message": "MCP connections are shutting down.",
"requires_restart": True,
}
try:
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
) )
except BaseException as exc:
self._set_runtime_status(missing_servers, "failed")
logger.warning(
"Failed to connect MCP servers "
"(will retry on the next readiness check): {}",
exc,
)
async def reload(self) -> dict[str, Any]:
"""Reconcile live MCP connections with the current configuration."""
async with self._lock:
if self._closing:
return self._closing_result()
try:
next_servers = dict(self._server_loader())
except Exception as exc: except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc) logger.warning("MCP hot reload could not read config: {}", exc)
return { return {
@@ -1439,7 +1489,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"error": str(exc), "error": str(exc),
} }
current_servers = dict(state._mcp_servers) current_servers = dict(self._servers)
current_names = set(current_servers) current_names = set(current_servers)
next_names = set(next_servers) next_names = set(next_servers)
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
@@ -1454,52 +1504,54 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
changed = sorted( changed = sorted(
name name
for name in current_names & next_names for name in current_names & next_names
if _server_signature(current_servers[name]) != _server_signature(next_servers[name]) if _server_signature(current_servers[name])
!= _server_signature(next_servers[name])
) )
tools_removed = 0 tools_removed = 0
for name in [*removed, *changed]: for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(registry, name) tools_removed += _unregister_server_tools(self._registry, name)
await _close_server(state, name) await self._close_server(name)
runtime_statuses = _runtime_status_store(state)
if runtime_statuses is not None:
for name in [*removed, *authorization_pending]: for name in [*removed, *authorization_pending]:
runtime_statuses.pop(name, None) self._runtime_statuses.pop(name, None)
state._mcp_servers = next_servers self._servers = next_servers
retry_missing = sorted( retry_missing = sorted(
name name
for name in next_names for name in next_names
if name not in state._mcp_stacks if name not in self._connections
and name not in set(added) | set(changed) and name not in set(added) | set(changed)
and name not in authorization_pending and name not in authorization_pending
) )
to_connect_names = sorted( to_connect_names = sorted(
(set(added) | set(changed) | set(retry_missing)) - authorization_pending (set(added) | set(changed) | set(retry_missing))
- authorization_pending
) )
to_connect = {name: next_servers[name] for name in to_connect_names} to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {} connected: dict[str, MCPConnection] = {}
if to_connect: if to_connect:
_set_runtime_status(state, to_connect, "connecting") self._set_runtime_status(to_connect, "connecting")
connected = await connect_mcp_servers(to_connect, registry) try:
if getattr(state, "_mcp_closing", False): connected = await connect_mcp_servers(to_connect, self._registry)
for connection in connected.values(): except BaseException:
await connection.aclose() self._set_runtime_status(to_connect, "failed")
return { raise
"ok": False, if self._closing:
"message": "MCP connections are shutting down.", await _close_mcp_connections(connected)
"requires_restart": True, return self._closing_result()
} self._connections.update(connected)
state._mcp_stacks.update(connected) self._record_connection_result(to_connect, connected)
_record_connection_result(state, to_connect, connected) self._attach_reconnect_handlers(connected)
_attach_reconnect_handlers(state, registry, connected)
failed = sorted(set(to_connect) - set(connected)) failed = sorted(set(to_connect) - set(connected))
unchanged = not removed and not added and not changed and not retry_missing unchanged = not removed and not added and not changed and not retry_missing
ok = not failed ok = not failed
if failed: if failed:
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed) message = (
"MCP config reloaded, but some servers did not connect: "
+ ", ".join(failed)
)
elif unchanged: elif unchanged:
message = "MCP config is already live." message = "MCP config is already live."
elif retry_missing and not added and not changed and not removed: elif retry_missing and not added and not changed and not removed:
@@ -1508,7 +1560,8 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
message = "MCP config reloaded without restarting nanobot." message = "MCP config reloaded without restarting nanobot."
logger.info( logger.info(
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}", "MCP hot reload: added={} changed={} removed={} retried={} "
"connected={} failed={} tools_removed={}",
added, added,
changed, changed,
removed, removed,
@@ -1524,114 +1577,51 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"changed": changed, "changed": changed,
"removed": removed, "removed": removed,
"retried": retry_missing, "retried": retry_missing,
"connected": sorted(state._mcp_stacks), "connected": sorted(self._connections),
"configured": sorted(state._mcp_servers), "configured": sorted(self._servers),
"failed": failed, "failed": failed,
"tools_removed": tools_removed, "tools_removed": tools_removed,
"requires_restart": False, "requires_restart": False,
} }
@staticmethod
async def request_mcp_reload( def _closing_result() -> dict[str, Any]:
bus: MessageBus,
*,
timeout: float = 15.0,
) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
await bus.publish_inbound(
InboundMessage(
channel="system",
sender_id="webui-settings",
chat_id="runtime",
content=RUNTIME_CONTROL_MCP_RELOAD,
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
RUNTIME_CONTROL_ACK: ack,
},
)
)
try:
result = await asyncio.wait_for(ack, timeout=timeout)
except asyncio.TimeoutError:
return { return {
"ok": False, "ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.", "message": "MCP connections are shutting down.",
"requires_restart": True,
}
return result if isinstance(cast(object, result), dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True, "requires_restart": True,
} }
def _attach_reconnect_handlers(self, server_names: Iterable[str]) -> None:
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool: async def reconnect(
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {} server_name: str,
control = metadata.get(INBOUND_META_RUNTIME_CONTROL) tool_name: str,
if control != RUNTIME_CONTROL_MCP_RELOAD: stale_tool: Tool,
return False ) -> Tool | None:
return await self._refresh_terminated_server(
ack = metadata.get(RUNTIME_CONTROL_ACK)
try:
result = await reload_servers(state, registry)
except Exception as exc:
logger.exception("MCP hot reload failed")
result = {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
return True
def _reload_lock(state: Any) -> asyncio.Lock:
try:
return _RELOAD_LOCKS[state]
except KeyError:
lock = asyncio.Lock()
_RELOAD_LOCKS[state] = lock
return lock
def _attach_reconnect_handlers(
state: Any,
registry: ToolRegistry,
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
) -> None:
async def reconnect(server_name: str, tool_name: str, stale_tool: Tool) -> Tool | None:
return await _refresh_terminated_server(
state,
registry,
server_name, server_name,
tool_name, tool_name,
stale_tool, stale_tool,
) )
for server_name in server_names: for server_name in server_names:
for tool_name in list(registry.tool_names): for tool_name in list(self._registry.tool_names):
tool = registry.get(tool_name) tool = self._registry.get(tool_name)
if not _tool_belongs_to_server(tool, tool_name, server_name): if not _tool_belongs_to_server(tool, tool_name, server_name):
continue continue
if isinstance(tool, _MCPWrapperBase): if isinstance(tool, _MCPWrapperBase):
tool.set_reconnect_handler(reconnect) tool.set_reconnect_handler(reconnect)
async def _refresh_terminated_server(
async def _refresh_terminated_server( self,
state: Any,
registry: ToolRegistry,
server_name: str, server_name: str,
tool_name: str, tool_name: str,
stale_tool: Tool, stale_tool: Tool,
) -> Tool | None: ) -> Tool | None:
async with _reload_lock(state): async with self._lock:
if getattr(state, "_mcp_closing", False): if self._closing:
return None return None
cfg = state._mcp_servers.get(server_name) cfg = self._servers.get(server_name)
if cfg is None: if cfg is None:
logger.warning( logger.warning(
"MCP server '{}' session terminated but is no longer configured", "MCP server '{}' session terminated but is no longer configured",
@@ -1639,31 +1629,56 @@ async def _refresh_terminated_server(
) )
return None return None
current_tool = registry.get(tool_name) current_tool = self._registry.get(tool_name)
if ( if (
current_tool is not None current_tool is not None
and current_tool is not stale_tool and current_tool is not stale_tool
and server_name in state._mcp_stacks and server_name in self._connections
): ):
return current_tool return current_tool
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name) logger.warning(
_unregister_server_tools(registry, server_name) "MCP server '{}' session terminated; refreshing connection",
await _close_server(state, server_name) server_name,
)
_unregister_server_tools(self._registry, server_name)
await self._close_server(server_name)
_set_runtime_status(state, {server_name}, "connecting") self._set_runtime_status({server_name}, "connecting")
connected = await connect_mcp_servers({server_name: cfg}, registry) connected = await connect_mcp_servers(
if getattr(state, "_mcp_closing", False): {server_name: cfg},
for connection in connected.values(): self._registry,
await connection.aclose() )
if self._closing:
await _close_mcp_connections(connected)
return None return None
state._mcp_stacks.update(connected) self._connections.update(connected)
_record_connection_result(state, {server_name}, connected) self._record_connection_result({server_name}, connected)
_attach_reconnect_handlers(state, registry, connected) self._attach_reconnect_handlers(connected)
if server_name not in connected: if server_name not in connected:
logger.warning("MCP server '{}' reconnect failed after session termination", server_name) logger.warning(
"MCP server '{}' reconnect failed after session termination",
server_name,
)
return None return None
return registry.get(tool_name) return self._registry.get(tool_name)
async def _close_server(self, server_name: str) -> None:
connection = self._connections.pop(server_name, None)
if connection is None:
return
await _close_mcp_connection(server_name, connection)
async def aclose(self) -> None:
"""Close every connection while excluding reconnect and hot reload."""
self._closing = True
async with self._lock:
connections = dict(self._connections)
self._connections.clear()
self._runtime_statuses.clear()
for name in self._servers:
_unregister_server_tools(self._registry, name)
await _close_mcp_connections(connections)
def _server_signature(cfg: Any) -> Any: def _server_signature(cfg: Any) -> Any:
@@ -1690,37 +1705,3 @@ def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
registry.unregister(tool_name) registry.unregister(tool_name)
removed += 1 removed += 1
return removed return removed
async def _close_server(state: Any, server_name: str) -> None:
stack = state._mcp_stacks.pop(server_name, None)
if stack is None:
return
try:
await stack.aclose()
except asyncio.CancelledError:
if task_is_cancelling():
raise
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
async def close_mcp_servers(state: Any) -> None:
"""Close every MCP connection while excluding reconnect and hot reload."""
state._mcp_closing = True
async with _reload_lock(state):
connections = list(state._mcp_stacks.items())
state._mcp_stacks.clear()
statuses = _runtime_status_store(state)
if statuses is not None:
statuses.clear()
for name, connection in connections:
try:
await connection.aclose()
except asyncio.CancelledError:
if task_is_cancelling():
raise
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
+1 -1
View File
@@ -78,7 +78,7 @@ class MyTool(Tool):
"runner", "sessions", "consolidator", "runner", "sessions", "consolidator",
"dream", "auto_compact", "context", "commands", "dream", "auto_compact", "context", "commands",
# Sensitive runtime state (credentials, message routing, task tracking) # Sensitive runtime state (credentials, message routing, task tracking)
"_mcp_servers", "_mcp_stacks", "_pending_queues", "_pending_queues",
"_session_locks", "_active_tasks", "_background_tasks", "_session_locks", "_active_tasks", "_background_tasks",
# Security boundaries (inspect + modify both blocked) # Security boundaries (inspect + modify both blocked)
"restrict_to_workspace", "channels_config", "restrict_to_workspace", "channels_config",
+221 -15
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import re import re
import shlex
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -12,7 +13,8 @@ import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
from typing import Any from typing import Any, Protocol, cast
from urllib.parse import unquote
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
@@ -42,6 +44,17 @@ from nanobot.security.workspace_access import current_scope_allows_loopback, cur
from nanobot.security.workspace_policy import is_path_within from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32" _IS_WINDOWS = sys.platform == "win32"
_PROCESS_TREE_OWNER_ATTR = "_nanobot_process_tree_owner"
class _ProcessTreeOwner(Protocol):
creation_flags: int
def assign_and_resume(self, pid: int) -> None: ...
def release(self) -> None: ...
def terminate(self) -> None: ...
def _reap_pid(pid: int) -> None: def _reap_pid(pid: int) -> None:
@@ -326,6 +339,7 @@ class ExecTool(Tool):
prepared.env, prepared.env,
prepared.shell_program, prepared.shell_program,
prepared.login, prepared.login,
process_tree=True,
) )
try: try:
@@ -334,10 +348,10 @@ class ExecTool(Tool):
timeout=prepared.timeout, timeout=prepared.timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._kill_process(process) await self._kill_process_tree(process)
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds") return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
except asyncio.CancelledError: except asyncio.CancelledError:
await self._kill_process(process) await self._kill_process_tree(process)
raise raise
# Safety-net reap: asyncio *should* have reaped the child via # Safety-net reap: asyncio *should* have reaped the child via
@@ -368,13 +382,14 @@ class ExecTool(Tool):
+ result[-half:] + result[-half:]
) )
self._release_process_tree(process)
return result return result
except Exception as e: except Exception as e:
# Kill and reap the child if it was spawned but an unexpected # Kill and reap the child if it was spawned but an unexpected
# error prevented communicate() from completing. # error prevented communicate() from completing.
if process is not None: if process is not None:
await self._kill_process(process) await self._kill_process_tree(process)
return ToolResult.error(f"Error executing command: {str(e)}") return ToolResult.error(f"Error executing command: {str(e)}")
async def _execute_session( async def _execute_session(
@@ -537,22 +552,31 @@ class ExecTool(Tool):
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell.""" """Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS: if _IS_WINDOWS:
windows_job = None
process = None
creation_flags = 0
if process_tree and sys.platform == "win32":
windows_job = ExecTool._create_windows_job()
creation_flags = windows_job.creation_flags
# Default to PowerShell so single-line and multi-line commands # Default to PowerShell so single-line and multi-line commands
# share the same shell semantics. cmd.exe is reachable via the # share the same shell semantics. cmd.exe is reachable via the
# explicit shell="cmd" parameter (see _resolve_shell). # explicit shell="cmd" parameter (see _resolve_shell).
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell" default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
program = shell_program or default_program program = shell_program or default_program
program_name = PureWindowsPath(program).name.lower() program_name = PureWindowsPath(program).name.lower()
try:
if program_name in ("cmd", "cmd.exe"): if program_name in ("cmd", "cmd.exe"):
cmd_env = {**env, "COMSPEC": program} cmd_env = {**env, "COMSPEC": program}
return await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
stdin=stdin, stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=cmd_env, env=cmd_env,
creationflags=creation_flags,
) )
else:
command = ExecTool._normalize_powershell_command(command) command = ExecTool._normalize_powershell_command(command)
command = ( command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
@@ -561,14 +585,25 @@ class ExecTool(Tool):
f"{command}\n" f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }" "if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
) )
return await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
program, "-NoProfile", "-NonInteractive", "-Command", command, program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin, stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env, env=env,
creationflags=creation_flags,
) )
if windows_job is not None:
windows_job.assign_and_resume(process.pid)
setattr(process, _PROCESS_TREE_OWNER_ATTR, windows_job)
return process
except BaseException:
if windows_job is not None:
windows_job.terminate()
if process is not None:
await ExecTool._kill_process(process)
raise
shell_program = shell_program or shutil.which("bash") or "/bin/bash" shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args: list[str] = [shell_program] args: list[str] = [shell_program]
shell_name = Path(shell_program).name.lower() shell_name = Path(shell_program).name.lower()
@@ -687,11 +722,12 @@ class ExecTool(Tool):
@staticmethod @staticmethod
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None: async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
"""Kill a session process and descendants, then reap the root process.""" """Kill a session process and descendants, then reap the root process."""
if process.returncode is not None: owner = ExecTool._process_tree_owner(process)
_reap_pid(process.pid)
return
try: try:
if _IS_WINDOWS: if owner is not None:
owner.terminate()
elif _IS_WINDOWS:
if process.returncode is None:
with suppress(OSError, asyncio.TimeoutError): with suppress(OSError, asyncio.TimeoutError):
await asyncio.wait_for( await asyncio.wait_for(
asyncio.to_thread( asyncio.to_thread(
@@ -715,8 +751,36 @@ class ExecTool(Tool):
with suppress(asyncio.TimeoutError): with suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), timeout=5.0) await asyncio.wait_for(process.wait(), timeout=5.0)
finally: finally:
if owner is not None:
ExecTool._drop_process_tree_owner(process)
_reap_pid(process.pid) _reap_pid(process.pid)
@staticmethod
def _process_tree_owner(
process: asyncio.subprocess.Process,
) -> _ProcessTreeOwner | None:
# _spawn is the only writer for this private ownership marker.
return cast(_ProcessTreeOwner | None, vars(process).get(_PROCESS_TREE_OWNER_ATTR))
@staticmethod
def _create_windows_job() -> _ProcessTreeOwner:
from nanobot.agent.tools._windows_job import WindowsJob
return WindowsJob.create()
@staticmethod
def _drop_process_tree_owner(process: asyncio.subprocess.Process) -> None:
with suppress(AttributeError):
delattr(process, _PROCESS_TREE_OWNER_ATTR)
@staticmethod
def _release_process_tree(process: asyncio.subprocess.Process) -> None:
owner = ExecTool._process_tree_owner(process)
if owner is None:
return
owner.release()
ExecTool._drop_process_tree_owner(process)
def _build_env(self) -> dict[str, str]: def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution. """Build a minimal environment for subprocess execution.
@@ -826,12 +890,27 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd): for raw in self._extract_absolute_paths(cmd):
try: try:
expanded = os.path.expandvars(raw.strip()) expanded = os.path.expandvars(raw.strip())
# Python's expanduser() intentionally does not implement
# shell directory-stack forms. ``~+`` is the active cwd,
# while ``~-`` and indexed forms can resolve outside it;
# normalize the former and fail closed on the latter.
if expanded == "~+":
p = cwd_path
elif expanded.startswith("~+/"):
p = (cwd_path / expanded[3:]).resolve()
elif re.match(r"^~(?:-|[+-]\d+)(?:/|$)", expanded):
return ToolResult.error(
"Error: Command blocked by safety guard "
"(path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
else:
p = Path(expanded).expanduser().resolve()
# Match against the un-resolved path first. On Linux, # Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and # /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent. # ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded): if self._is_benign_device_path(expanded):
continue continue
p = Path(expanded).expanduser().resolve()
except Exception: except Exception:
continue continue
@@ -914,7 +993,9 @@ class ExecTool(Tool):
): ):
current.append(ch) current.append(ch)
operator_len = 1 operator_len = 1
elif ch in {";", "|"}: # A newline separates commands just like ";" does, so a payload
# smuggled onto its own line must be checked on its own too.
elif ch in {";", "|", "\n", "\r"}:
operator_len = 1 operator_len = 1
if operator_len: if operator_len:
@@ -948,9 +1029,134 @@ class ExecTool(Tool):
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command command
) )
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only try:
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+ lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&")
return win_paths + posix_paths + home_paths lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
# Keep malformed quoting fail-closed. The shell will normally reject
# it too, but a conservative raw scan must not turn it into a bypass.
tokens = [command]
paths = [*win_paths]
seen = set(win_paths)
for index, token in enumerate(tokens):
for path in ExecTool._extract_posix_paths_from_token(token):
if path not in seen:
paths.append(path)
seen.add(path)
if index > 0 and tokens[index - 1] in {"-c", "-lc", "--command"}:
for path in ExecTool._extract_absolute_paths(token):
if path not in seen:
paths.append(path)
seen.add(path)
return paths
@staticmethod
def _extract_posix_paths_from_token(token: str) -> list[str]:
"""Extract local POSIX/home paths from one shell-decoded token.
``shlex`` separates real grouping/redirection operators while preserving
parentheses and spaces that were quoted or escaped as part of a path.
Embedded scripts (for example ``sh -c \"cat /tmp/x\"``) still need a
small boundary scan. Colons are not general boundaries: treating them
as such misclassifies URLs, ``host:/remote`` and ``C:/Windows``. They
are considered only inside a syntactically valid assignment, where
shells expand each colon-delimited tilde component.
"""
paths: list[str] = []
for match in re.finditer(
r"file://(?:[^/\s\"']+)?(/[^\s\"'<>|;&]*)",
token,
flags=re.IGNORECASE,
):
uri_prefix = token[: match.start()]
raw_path = match.group(1)
if uri_prefix.count("(") > uri_prefix.count(")"):
raw_path = raw_path.split(")", 1)[0]
if uri_prefix.count("{") > uri_prefix.count("}"):
raw_path = raw_path.split(",", 1)[0].split("}", 1)[0]
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
if raw_path:
paths.append(unquote(raw_path))
boundary_chars = frozenset(" \t\r\n=({,<>|;&\"'")
i = 0
while i < len(token):
is_posix = token[i] == "/"
home_match = re.match(
r"~(?:[+-](?:\d+)?|[A-Za-z0-9_.@-]+)?(?=/|:|$)",
token[i:],
)
is_home = home_match is not None
if not is_posix and not is_home:
i += 1
continue
prefix = token[:i]
parameter_default = (
i >= 2 and token[i - 2] == ":" and token[i - 1] in "-+?="
)
word_start = max(
(prefix.rfind(char) for char in " \t\r\n<>|;&"),
default=-1,
) + 1
word_prefix = prefix[word_start:]
assignment_component = bool(
re.fullmatch(
r"(?:[A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9_.-]+)="
r"(?:[^:=\s]*:)*",
word_prefix,
)
)
at_boundary = i == 0 or token[i - 1] in boundary_chars
if is_home:
# A shell word beginning with ``~`` is a separate shlex token.
# Mid-token expansion is valid only after ``=`` or a colon in
# an assignment. This avoids PromQL/Loki ``=~`` and ``|~``
# match operators while covering PATH-like values.
at_boundary = i == 0 or assignment_component
if not at_boundary and not parameter_default:
i += 1
continue
if re.search(r"[A-Za-z][A-Za-z0-9+.-]*://", word_prefix) or re.match(
r"(?:[^/:=\s]+@)?[^/:=\s]+:$",
word_prefix,
):
# HTTP-style URL path/query fragments and scp-style remote paths
# are not local filesystem references. ``file://`` paths were
# decoded above. Windows drive paths are already captured by the
# platform-specific expression above.
i += 1
continue
assignment_value = assignment_component
if i == 0 or assignment_value:
end = len(token)
if assignment_value:
separator = token.find(":", i)
if separator >= 0:
end = separator
elif token[i - 1] in {"'", '"'}:
quote = token[i - 1]
closing = token.find(quote, i)
end = len(token) if closing < 0 else closing
else:
end_chars = set(" \t\r\n\"'<>|;&")
if prefix.count("(") > prefix.count(")"):
end_chars.add(")")
if prefix.count("{") > prefix.count("}"):
end_chars.update({",", "}"})
end = i
while end < len(token) and token[end] not in end_chars:
end += 1
candidate = token[i:end]
if candidate:
paths.append(candidate)
i = max(end, i + 1)
return paths
@staticmethod @staticmethod
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]: def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
+120 -17
View File
@@ -11,7 +11,7 @@ import os
import re import re
from collections.abc import Callable from collections.abc import Callable
from typing import Any, cast from typing import Any, cast
from urllib.parse import quote, urljoin, urlparse from urllib.parse import parse_qsl, quote, urljoin, urlparse
import httpx import httpx
from loguru import logger from loguru import logger
@@ -148,6 +148,59 @@ def _unsafe_url_request_error(exc: BaseException) -> str | None:
return str(exc) if isinstance(exc, UnsafeURLRequestError) else None return str(exc) if isinstance(exc, UnsafeURLRequestError) else None
# Forwarding a URL to the remote Jina reader discloses it to a third party, so
# URLs that embed credential material (userinfo, signed-URL parameters, token
# or key query values) must never leave the machine. Matching is by parameter
# name: over-matching only costs the local readability fallback, while
# under-matching leaks a secret.
_CREDENTIAL_QUERY_PARAMS = frozenset({
"access_token", "api-key", "api-token", "apikey", "api_key", "api_token",
"auth", "authorization", "client_assertion", "client_secret", "code",
"credential", "credentials", "id_token", "jwt", "key", "password",
"passwd", "private_key", "pwd", "refresh_token", "samlresponse", "secret",
"session_id", "session_token", "sessionid", "sig", "signature", "sso_token",
"ticket", "token",
})
_CREDENTIAL_QUERY_PREFIXES = ("x-amz-", "x-goog-")
def _url_carries_credentials(url: str) -> bool:
try:
parsed = urlparse(url)
except ValueError:
return True
if parsed.username is not None or parsed.password is not None:
return True
# Some frameworks still accept semicolons as query separators. Treating
# them as separators here may over-match a value, but the safe consequence
# is only using the local extractor instead of disclosing a credential.
query = parsed.query.replace(";", "&")
for name, _value in parse_qsl(query, keep_blank_values=True):
lowered = name.strip().lower()
if lowered in _CREDENTIAL_QUERY_PARAMS or lowered.startswith(_CREDENTIAL_QUERY_PREFIXES):
return True
return False
def _redact_url_for_log(url: str) -> str:
"""Return only a URL's origin, excluding userinfo, path, query, and fragment."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not parsed.scheme or hostname is None:
return "<redacted URL>"
if ":" in hostname:
hostname = f"[{hostname}]"
try:
port = parsed.port
except ValueError:
port = None
authority = f"{hostname}:{port}" if port is not None else hostname
return f"{parsed.scheme}://{authority}"
except ValueError:
return "<redacted URL>"
async def _get_with_safe_redirects( async def _get_with_safe_redirects(
client: httpx.AsyncClient, client: httpx.AsyncClient,
url: str, url: str,
@@ -191,13 +244,14 @@ async def _stream_with_safe_redirects(
client: httpx.AsyncClient, client: httpx.AsyncClient,
url: str, url: str,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, Any | None, str | None]: ) -> tuple[httpx.Response | None, Any | None, str | None, bool]:
"""Open a streamed response while validating every redirect target first.""" """Open a streamed response while validating every redirect target first."""
current_url = url current_url = url
chain_carries_credentials = _url_carries_credentials(url)
for _ in range(MAX_REDIRECTS + 1): for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg, _ = _resolve_url_safe(current_url) is_valid, error_msg, _ = _resolve_url_safe(current_url)
if not is_valid: if not is_valid:
return None, None, f"Redirect blocked: {error_msg}" return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
stream = client.stream( stream = client.stream(
"GET", "GET",
@@ -210,26 +264,39 @@ async def _stream_with_safe_redirects(
except httpx.RequestError as exc: except httpx.RequestError as exc:
unsafe_error = _unsafe_url_request_error(exc) unsafe_error = _unsafe_url_request_error(exc)
if unsafe_error is not None: if unsafe_error is not None:
return None, None, f"Redirect blocked: {unsafe_error}" return (
None,
None,
f"Redirect blocked: {unsafe_error}",
chain_carries_credentials,
)
raise raise
is_redirect = 300 <= response.status_code < 400 is_redirect = 300 <= response.status_code < 400
if not is_redirect: if not is_redirect:
return response, stream, None return response, stream, None, chain_carries_credentials
location = response.headers.get("location") location = response.headers.get("location")
if not location: if not location:
return response, stream, None return response, stream, None, chain_carries_credentials
next_url = urljoin(str(response.url), location) next_url = urljoin(str(response.url), location)
chain_carries_credentials = (
chain_carries_credentials or _url_carries_credentials(next_url)
)
is_valid, error_msg = _validate_url_safe(next_url) is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid: if not is_valid:
await stream.__aexit__(None, None, None) await stream.__aexit__(None, None, None)
return None, None, f"Redirect blocked: {error_msg}" return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
await stream.__aexit__(None, None, None) await stream.__aexit__(None, None, None)
current_url = next_url current_url = next_url
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}" return (
None,
None,
f"Too many redirects: exceeded limit of {MAX_REDIRECTS}",
chain_carries_credentials,
)
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str: def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
@@ -1043,20 +1110,26 @@ class WebFetchTool(Tool):
if not is_valid: if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
# Detect and fetch images directly to avoid Jina's textual image captioning # Detect and fetch images directly to avoid Jina's textual image captioning.
# This local preflight also proves that no credential-bearing URL occurs
# in the redirect chain before the original URL may be sent to Jina.
jina_remote_safe = False
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
**_fetch_client_kwargs(self.proxy, 15.0), **_fetch_client_kwargs(self.proxy, 15.0),
) as client: ) as client:
r, stream, redirect_error = await _stream_with_safe_redirects( r, stream, redirect_error, chain_carries_credentials = (
await _stream_with_safe_redirects(
client, client,
url, url,
headers={"User-Agent": self.user_agent}, headers={"User-Agent": self.user_agent},
) )
)
if redirect_error: if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False) return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None: if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False) return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
jina_remote_safe = not chain_carries_credentials
try: try:
ctype = r.headers.get("content-type", "") ctype = r.headers.get("content-type", "")
@@ -1071,10 +1144,14 @@ class WebFetchTool(Tool):
unsafe_error = _unsafe_url_request_error(e) unsafe_error = _unsafe_url_request_error(e)
if unsafe_error is not None: if unsafe_error is not None:
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False) return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
logger.debug("Pre-fetch image detection failed for {}: {}", url, e) logger.debug(
"Pre-fetch image detection failed for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
result = None result = None
if self.config.use_jina_reader: if self.config.use_jina_reader and jina_remote_safe:
result = await self._fetch_jina(url, max_chars) result = await self._fetch_jina(url, max_chars)
if result is None: if result is None:
result = await self._fetch_readability(url, extract_mode, max_chars) result = await self._fetch_readability(url, extract_mode, max_chars)
@@ -1082,13 +1159,23 @@ class WebFetchTool(Tool):
async def _fetch_jina(self, url: str, max_chars: int) -> str | None: async def _fetch_jina(self, url: str, max_chars: int) -> str | None:
"""Try fetching via Jina Reader API. Returns None on failure.""" """Try fetching via Jina Reader API. Returns None on failure."""
if _url_carries_credentials(url):
logger.debug(
"Skipping Jina Reader for {}: URL carries credential material",
_redact_url_for_log(url),
)
return None
# httpx already drops the fragment when building the request; strip it
# explicitly so client-side-only data (OAuth implicit flows put tokens
# there) stays out of this path even if the transport changes.
forwarded_url = url.split("#", 1)[0]
try: try:
headers = {"Accept": "application/json", "User-Agent": self.user_agent} headers = {"Accept": "application/json", "User-Agent": self.user_agent}
jina_key = os.environ.get("JINA_API_KEY", "") jina_key = os.environ.get("JINA_API_KEY", "")
if jina_key: if jina_key:
headers["Authorization"] = f"Bearer {jina_key}" headers["Authorization"] = f"Bearer {jina_key}"
async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client: async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client:
r = await client.get(f"https://r.jina.ai/{url}", headers=headers) r = await client.get(f"https://r.jina.ai/{forwarded_url}", headers=headers)
if r.status_code == 429: if r.status_code == 429:
logger.debug("Jina Reader rate limited, falling back to readability") logger.debug("Jina Reader rate limited, falling back to readability")
return None return None
@@ -1113,7 +1200,11 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text, "untrusted": True, "text": text,
}, ensure_ascii=False) }, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.debug("Jina Reader failed for {}, falling back to readability: {}", url, e) logger.debug(
"Jina Reader failed for {}, falling back to readability ({})",
_redact_url_for_log(url),
type(e).__name__,
)
return None return None
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any: async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
@@ -1144,7 +1235,11 @@ class WebFetchTool(Tool):
text = self._extract_readable_html(r.text, extract_mode) text = self._extract_readable_html(r.text, extract_mode)
extractor = "readability" extractor = "readability"
except Exception as e: except Exception as e:
logger.warning("Readability failed for {}, using raw HTML fallback: {}", url, e) logger.warning(
"Readability failed for {}, using raw HTML fallback ({})",
_redact_url_for_log(url),
type(e).__name__,
)
text, extractor = _normalize(_strip_tags(r.text)), "html" text, extractor = _normalize(_strip_tags(r.text)), "html"
else: else:
text, extractor = r.text, "raw" text, extractor = r.text, "raw"
@@ -1160,10 +1255,18 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text, "untrusted": True, "text": text,
}, ensure_ascii=False) }, ensure_ascii=False)
except httpx.ProxyError as e: except httpx.ProxyError as e:
logger.exception("WebFetch proxy error for {}", url) logger.warning(
"WebFetch proxy error for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.exception("WebFetch error for {}", url) logger.warning(
"WebFetch error for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str: def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
+21 -8
View File
@@ -48,6 +48,7 @@ _AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name") _MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout") _REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks") _SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
_PREPARE_AGENT_KEY = web.AppKey[Callable[[], Awaitable[None]] | None]("prepare_agent")
_MISSING = object() _MISSING = object()
@@ -66,6 +67,17 @@ def _app_value(
return app.get(legacy_key, default) return app.get(legacy_key, default)
async def _prepare_agent(app: Any) -> None:
prepare: Callable[[], Awaitable[None]] | None = _app_value(
app,
_PREPARE_AGENT_KEY,
"prepare_agent",
None,
)
if prepare is not None:
await prepare()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Response helpers # Response helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -346,8 +358,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
nonlocal stream_failed nonlocal stream_failed
try: try:
async with session_lock: async with session_lock:
response = await asyncio.wait_for( async with asyncio.timeout(timeout_s):
agent_loop.process_direct( await _prepare_agent(request.app)
response = await agent_loop.process_direct(
content=text, content=text,
media=media_paths if media_paths else None, media=media_paths if media_paths else None,
session_key=session_key, session_key=session_key,
@@ -355,8 +368,6 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
chat_id=API_CHAT_ID, chat_id=API_CHAT_ID,
on_stream=_on_stream, on_stream=_on_stream,
on_stream_end=_on_stream_end, on_stream_end=_on_stream_end,
),
timeout=timeout_s,
) )
if not emitted_content: if not emitted_content:
response_text = _response_text(response) response_text = _response_text(response)
@@ -390,15 +401,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
try: try:
async with session_lock: async with session_lock:
try: try:
response = await asyncio.wait_for( async with asyncio.timeout(timeout_s):
agent_loop.process_direct( await _prepare_agent(request.app)
response = await agent_loop.process_direct(
content=text, content=text,
media=media_paths if media_paths else None, media=media_paths if media_paths else None,
session_key=session_key, session_key=session_key,
channel="api", channel="api",
chat_id=API_CHAT_ID, chat_id=API_CHAT_ID,
),
timeout=timeout_s,
) )
response_text = _response_text(response) response_text = _response_text(response)
if not response_text or not response_text.strip(): if not response_text or not response_text.strip():
@@ -452,6 +462,7 @@ def create_app(
model_name: str = "nanobot", model_name: str = "nanobot",
request_timeout: float = 120.0, request_timeout: float = 120.0,
api_key: str = "", api_key: str = "",
prepare_agent: Callable[[], Awaitable[None]] | None = None,
) -> web.Application: ) -> web.Application:
"""Create the aiohttp application. """Create the aiohttp application.
@@ -460,12 +471,14 @@ def create_app(
model_name: Model name reported in responses. model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds. request_timeout: Per-request timeout in seconds.
api_key: Optional API key for Bearer-token authentication on API routes. api_key: Optional API key for Bearer-token authentication on API routes.
prepare_agent: Optional application-owned readiness callback run before each turn.
""" """
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app[_AGENT_LOOP_KEY] = agent_loop app[_AGENT_LOOP_KEY] = agent_loop
app[_MODEL_NAME_KEY] = model_name app[_MODEL_NAME_KEY] = model_name
app[_REQUEST_TIMEOUT_KEY] = request_timeout app[_REQUEST_TIMEOUT_KEY] = request_timeout
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
app[_PREPARE_AGENT_KEY] = prepare_agent
@web.middleware @web.middleware
async def auth_middleware( async def auth_middleware(
+1
View File
@@ -1029,6 +1029,7 @@ class CliAppManager:
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
timeout=timeout, timeout=timeout,
env=self._subprocess_env(),
) )
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command) logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
output = (result.stderr or result.stdout or "").strip() output = (result.stderr or result.stdout or "").strip()
-1
View File
@@ -16,7 +16,6 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
# loop to update runtime state without going through a user session. # loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control" INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack" RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload" RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard" RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
+7 -1
View File
@@ -1,7 +1,13 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types"; import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel"; const FeishuAssistantsPanel = lazy(() =>
import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({
default: component,
})),
);
export default { export default {
Panel: FeishuAssistantsPanel, Panel: FeishuAssistantsPanel,
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import inspect import inspect
from collections.abc import Callable, Iterable, Mapping from collections.abc import Awaitable, Callable, Iterable, Mapping
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -101,6 +101,7 @@ class ChannelManager:
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None, webui_runtime_capabilities: dict[str, Any] | None = None,
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None, webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None, config_path: Path | None = None,
): ):
@@ -121,6 +122,7 @@ class ChannelManager:
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_mcp_runtime_status = webui_mcp_runtime_status self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_mcp_reload = webui_mcp_reload
self._webui_skill_state_action = webui_skill_state_action self._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {} self._channel_owners: dict[str, str] = {}
@@ -190,6 +192,7 @@ class ChannelManager:
channel_feature_action=self.apply_channel_feature_action, channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status, channel_runtime_status=self.get_status,
mcp_runtime_status=self._webui_mcp_runtime_status, mcp_runtime_status=self._webui_mcp_runtime_status,
mcp_reload=self._webui_mcp_reload,
skill_state_action=self._webui_skill_state_action, skill_state_action=self._webui_skill_state_action,
logger=logger, logger=logger,
) )
+15 -1
View File
@@ -530,6 +530,10 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("failed to send {} event: {}", event, e) self.logger.warning("failed to send {} event: {}", event, e)
async def _broadcast_webui_event(self, event: str, **fields: Any) -> None:
for connection in tuple(self._webui_connections):
await self._send_event(connection, event, **fields)
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
return WebSocketConfig().model_dump(by_alias=True) return WebSocketConfig().model_dump(by_alias=True)
@@ -848,7 +852,7 @@ class WebSocketChannel(BaseChannel):
) )
return return
try: try:
await asyncio.to_thread( saved_state = await asyncio.to_thread(
write_webui_sidebar_state, write_webui_sidebar_state,
cast(dict[str, Any], state), cast(dict[str, Any], state),
) )
@@ -859,6 +863,11 @@ class WebSocketChannel(BaseChannel):
detail="invalid_sidebar_state", detail="invalid_sidebar_state",
) )
return return
await self._broadcast_webui_event(
"sidebar_state_updated",
state=saved_state,
)
return
if t == "set_workspace_scope": if t == "set_workspace_scope":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
@@ -1207,6 +1216,11 @@ class WebSocketChannel(BaseChannel):
message="WebUI mutation returned an invalid response", message="WebUI mutation returned an invalid response",
) )
return return
if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event(
"sidebar_state_updated",
state=result,
)
await self._send_webui_response( await self._send_webui_response(
connection, connection,
request_id, request_id,
@@ -0,0 +1,14 @@
"""Shared isolation for WebSocket tests that persist runtime state."""
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def isolate_websocket_runtime_data(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Keep transcripts and other runtime files out of the active user data directory."""
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
@@ -717,7 +717,7 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
bus, bus,
port=port, port=port,
token="static-token", token="static-token",
tokenIssuePath="/auth/token", tokenIssuePath="/custom-token",
websocketRequiresToken=True, websocketRequiresToken=True,
) )
@@ -725,15 +725,16 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
try: try:
denied = await _http_get(f"http://127.0.0.1:{port}/auth/token") denied = await _http_get(f"http://127.0.0.1:{port}/custom-token")
assert denied.status_code == 401 assert denied.status_code == 401
allowed = await _http_get( allowed = await _http_get(
f"http://127.0.0.1:{port}/auth/token", f"http://127.0.0.1:{port}/custom-token",
headers={"Authorization": "Bearer static-token"}, headers={"Authorization": "Bearer static-token"},
) )
assert allowed.status_code == 200 assert allowed.status_code == 200
assert allowed.json()["token"].startswith("nbwt_") assert allowed.json()["token"].startswith("nbwt_")
assert allowed.headers["Cache-Control"] == "no-store"
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task
@@ -1037,6 +1038,63 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
} }
@pytest.mark.asyncio
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
source = AsyncMock()
source.request = SimpleNamespace(headers=Headers())
other_device = AsyncMock()
channel._webui_connections.update({source, other_device})
request_id = "sidebar-workbench-state"
await channel._dispatch_envelope(
source,
"webui-client",
{
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {
"state": {
"workbench": {
"version": 1,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"title": "Research",
"paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"layout": "columns",
"splitRatios": [0.35],
}
},
}
}
},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
event = json.loads(other_device.send.await_args.args[0])
assert event["event"] == "sidebar_state_updated"
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
"websocket:a",
"websocket:b",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
"websocket:b",
"websocket:a",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
0.35
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None: async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
channel = _ch(bus) channel = _ch(bus)
@@ -3746,6 +3804,7 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
headers={"Authorization": "Bearer s"}, headers={"Authorization": "Bearer s"},
) )
assert resp.status_code == 429 assert resp.status_code == 429
assert resp.headers["Cache-Control"] == "no-store"
data = resp.json() data = resp.json()
assert "error" in data assert "error" in data
finally: finally:
@@ -31,6 +31,11 @@ from .ws_test_client import http_get as _http_get
_PORT = 29900 _PORT = 29900
@pytest.fixture(autouse=True)
def _isolate_runtime_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
class _MatrixChannel(BaseChannel): class _MatrixChannel(BaseChannel):
name = "matrix" name = "matrix"
display_name = "Matrix" display_name = "Matrix"
@@ -75,6 +80,7 @@ def _make_handler(
local_trigger_pending_ids: Any | None = None, local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None, channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None, channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
) -> GatewayServices: ) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd() workspace = workspace_path or Path.cwd()
@@ -94,6 +100,7 @@ def _make_handler(
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
) )
@@ -111,6 +118,7 @@ def _ch(
local_trigger_pending_ids: Any | None = None, local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None, channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None, channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
**extra: Any, **extra: Any,
) -> WebSocketChannel: ) -> WebSocketChannel:
cfg: dict[str, Any] = { cfg: dict[str, Any] = {
@@ -134,6 +142,7 @@ def _ch(
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
) )
return InProcessHttpChannel(cfg, bus, gateway=gateway) return InProcessHttpChannel(cfg, bus, gateway=gateway)
@@ -223,6 +232,7 @@ async def test_bootstrap_returns_token_for_localhost(
try: try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap") resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["Cache-Control"] == "no-store"
body = resp.json() body = resp.json()
assert body["token"].startswith("nbwt_") assert body["token"].startswith("nbwt_")
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui" assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
@@ -278,6 +288,53 @@ async def test_sessions_list_requires_bearer_token(
await server_task await server_task
@pytest.mark.asyncio
async def test_sessions_list_and_thread_restore_transcript_without_canonical_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = SessionManager(tmp_path / "workspace")
from nanobot.webui.transcript import append_transcript_object
key = "websocket:restored-history"
append_transcript_object(
key,
{"event": "user", "chat_id": "restored-history", "text": "original question"},
)
append_transcript_object(
key,
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
)
assert not sm._get_session_path(key).exists()
port = _free_port()
channel = _ch(bus, session_manager=sm, port=port)
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
listing = await _http_get(f"http://127.0.0.1:{port}/api/sessions", headers=auth)
thread = await _http_get(
f"http://127.0.0.1:{port}/api/sessions/"
"websocket%3Arestored-history/webui-thread",
headers=auth,
)
assert listing.status_code == 200
assert [row["key"] for row in listing.json()["sessions"]] == [key]
assert listing.json()["sessions"][0]["preview"] == "original question"
assert thread.status_code == 200
assert [message["content"] for message in thread.json()["messages"]] == [
"original question",
"original answer",
]
assert not sm._get_session_path(key).exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_session_messages_route_is_not_exposed( async def test_legacy_session_messages_route_is_not_exposed(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path
@@ -2054,14 +2111,15 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
_custom_action, _custom_action,
) )
async def _hot_reload(_bus): async def _hot_reload():
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False} return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
monkeypatch.setattr( channel = _ch(
"nanobot.webui.settings_routes.request_mcp_reload", bus,
_hot_reload, session_manager=_seed_session(tmp_path),
port=29913,
mcp_reload=_hot_reload,
) )
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
try: try:
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets") deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
@@ -2261,6 +2319,40 @@ async def test_session_delete_removes_file(
await server_task await server_task
@pytest.mark.asyncio
async def test_session_delete_removes_transcript_without_canonical_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = SessionManager(tmp_path / "workspace")
from nanobot.webui.transcript import append_transcript_object
key = "websocket:transcript-only"
append_transcript_object(
key,
{"event": "user", "chat_id": "transcript-only", "text": "recover me"},
)
assert not sm._get_session_path(key).exists()
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
assert webui_path.is_file()
channel = _ch(bus, session_manager=sm, port=_free_port())
server_task = asyncio.create_task(channel.start())
try:
response = await _webui_mutate(
channel,
"session.delete",
{"key": key},
)
assert response.status_code == 200
assert response.json()["deleted"] is True
assert not webui_path.exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path
+29
View File
@@ -486,6 +486,35 @@ class WeixinChannel(BaseChannel):
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
self._save_state(force=True) self._save_state(force=True)
self._persist_connect_credentials(token=token, base_url=base_url)
def _persist_connect_credentials(self, *, token: str, base_url: str) -> None:
"""Write the QR-login token and base_url back to config.json.
The connect flow saves account state to ``account.json`` (via
``_save_state``), but the WebUI's post-connect ``enable`` step calls
``set_channel_config_enabled`` which reads config.json. Without
persisting the token here, that step would overwrite it with the
default empty value, losing the freshly obtained credential.
"""
from nanobot.config.loader import get_config_path, load_config, save_config
try:
full_config = load_config()
section = getattr(full_config.channels, "weixin", None)
if section is not None and hasattr(section, "model_dump"):
values = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
values = dict(cast(dict[str, Any], section))
else:
values = {}
values["token"] = token
if base_url:
values["baseUrl"] = base_url
setattr(full_config.channels, "weixin", values)
save_config(full_config, get_config_path())
except Exception:
self.logger.exception("Failed to persist WeChat credentials to config.json")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# HTTP helpers (matches api.ts buildHeaders / apiFetch) # HTTP helpers (matches api.ts buildHeaders / apiFetch)
@@ -66,6 +66,63 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
assert saved["token"] == "wx-token" assert saved["token"] == "wx-token"
assert saved["base_url"] == "https://weixin.example" assert saved["base_url"] == "https://weixin.example"
# Token and base_url must also be persisted to config.json so the
# post-connect enable step does not overwrite them with empty defaults.
config_data = json.loads(config_path.read_text(encoding="utf-8"))
weixin_cfg = config_data.get("channels", {}).get("weixin", {})
assert weixin_cfg.get("token") == "wx-token"
assert weixin_cfg.get("baseUrl") == "https://weixin.example"
assert weixin_cfg.get("stateDir") == str(state_dir)
@pytest.mark.asyncio
async def test_weixin_connect_persists_credentials_without_channels_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When config.json has no channels key at all, connect must still write
the obtained token and base_url back to config.json."""
config_path = tmp_path / "config.json"
# config.json with NO channels key — the bug scenario
config_path.write_text(
json.dumps({"agents": {"defaults": {"model": "test"}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
self: WeixinChannel,
*,
base_url: str,
endpoint: str,
params: dict[str, Any],
auth: bool,
) -> dict[str, str]:
return {
"status": "confirmed",
"bot_token": "wx-token",
"baseurl": "https://weixin.example",
"ilink_user_id": "wx-user",
}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
config_data = json.loads(config_path.read_text(encoding="utf-8"))
weixin_cfg = config_data.get("channels", {}).get("weixin", {})
assert weixin_cfg.get("token") == "wx-token"
assert weixin_cfg.get("baseUrl") == "https://weixin.example"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds( async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
+4 -22
View File
@@ -33,28 +33,10 @@ import {
WEIXIN_AUTH_EXPIRED_MESSAGE, WEIXIN_AUTH_EXPIRED_MESSAGE,
WeixinConnectFlow, WeixinConnectFlow,
} from "./WeixinConnectFlow"; } from "./WeixinConnectFlow";
import {
export const WEIXIN_PRIMARY_FIELD_KEYS = [ WEIXIN_ADVANCED_FIELD_KEYS,
"channels.weixin.sendProgress", WEIXIN_PRIMARY_FIELD_KEYS,
"channels.weixin.sendToolHints", } from "./presentation";
"channels.weixin.streaming",
] as const;
export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
export function WeixinPanel({ export function WeixinPanel({
token, token,
+12 -3
View File
@@ -1,12 +1,21 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types"; import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { WeixinConnectFlow } from "./WeixinConnectFlow";
import { import {
WEIXIN_ADVANCED_FIELD_KEYS, WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS, WEIXIN_PRIMARY_FIELD_KEYS,
WeixinPanel, } from "./presentation";
} from "./WeixinPanel";
const WeixinPanel = lazy(() =>
import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })),
);
const WeixinConnectFlow = lazy(() =>
import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({
default: component,
})),
);
export default { export default {
Panel: WeixinPanel, Panel: WeixinPanel,
@@ -0,0 +1,21 @@
export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;
export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
+17 -2
View File
@@ -13,6 +13,8 @@ from rich.console import Console
from nanobot import __logo__ from nanobot import __logo__
from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
StreamDeltaEvent, StreamDeltaEvent,
StreamedResponseEvent, StreamedResponseEvent,
@@ -84,6 +86,8 @@ def agent(
# Create cron service with workspace-scoped store # Create cron service with workspace-scoped store
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json" cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(runtime_config, tools)
_set_nanobot_logs(logs) _set_nanobot_logs(logs)
@@ -95,6 +99,7 @@ def agent(
cron_service=cron, cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(runtime_config), image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
) )
except ValueError as exc: except ValueError as exc:
_print_agent_start_error(exc) _print_agent_start_error(exc)
@@ -106,6 +111,12 @@ def agent(
render_markdown=False, render_markdown=False,
) )
async def _close_runtime() -> None:
try:
await agent_loop.aclose()
finally:
await mcp_provider.aclose()
# Shared reference for progress callbacks # Shared reference for progress callbacks
_thinking: ThinkingSpinner | None = None _thinking: ThinkingSpinner | None = None
@@ -149,6 +160,8 @@ def agent(
if message: if message:
# Single message mode — direct call, no bus needed # Single message mode — direct call, no bus needed
async def run_once() -> None: async def run_once() -> None:
try:
await mcp_provider.connect()
renderer = StreamRenderer( renderer = StreamRenderer(
render_markdown=markdown, render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name, bot_name=runtime_config.agents.defaults.bot_name,
@@ -172,7 +185,8 @@ def agent(
metadata=response.metadata if response else None, metadata=response.metadata if response else None,
**print_kwargs, **print_kwargs,
) )
await agent_loop.close_mcp() finally:
await _close_runtime()
asyncio.run(run_once()) asyncio.run(run_once())
else: else:
@@ -209,6 +223,7 @@ def agent(
signal.signal(signal.SIGPIPE, signal.SIG_IGN) signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive() -> None: async def run_interactive() -> None:
await mcp_provider.connect()
bus_task = asyncio.create_task(agent_loop.run()) bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
@@ -347,6 +362,6 @@ def agent(
agent_loop.stop() agent_loop.stop()
outbound_task.cancel() outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp() await _close_runtime()
asyncio.run(run_interactive()) asyncio.run(run_interactive())
+49 -2
View File
@@ -49,6 +49,8 @@ from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402 from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402 from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.agent.tools.mcp import MCPProvider # noqa: E402
from nanobot.agent.tools.registry import ToolRegistry # noqa: E402
from nanobot.cli import terminal as cli_terminal # noqa: E402 from nanobot.cli import terminal as cli_terminal # noqa: E402
from nanobot.cli.agent import agent # noqa: E402 from nanobot.cli.agent import agent # noqa: E402
from nanobot.cli.gateway import create_gateway_app # noqa: E402 from nanobot.cli.gateway import create_gateway_app # noqa: E402
@@ -351,12 +353,15 @@ def serve(
sync_workspace_templates(runtime_config.workspace_path) sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus() bus = MessageBus()
session_manager = SessionManager(runtime_config.workspace_path) session_manager = SessionManager(runtime_config.workspace_path)
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(runtime_config, tools)
try: try:
agent_loop = AgentLoop.from_config( agent_loop = AgentLoop.from_config(
runtime_config, bus, runtime_config, bus,
session_manager=session_manager, session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(runtime_config), image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
) )
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
@@ -378,13 +383,17 @@ def serve(
api_app = create_app( api_app = create_app(
agent_loop, model_name=model_name, request_timeout=timeout, agent_loop, model_name=model_name, request_timeout=timeout,
api_key=api_key, api_key=api_key,
prepare_agent=mcp_provider.connect,
) )
async def on_startup(_app: Any) -> None: async def on_startup(_app: Any) -> None:
await agent_loop._connect_mcp() await mcp_provider.connect()
async def on_cleanup(_app: Any) -> None: async def on_cleanup(_app: Any) -> None:
await agent_loop.close_mcp() try:
await agent_loop.aclose()
finally:
await mcp_provider.aclose()
api_app.on_startup.append(on_startup) api_app.on_startup.append(on_startup)
api_app.on_cleanup.append(on_cleanup) api_app.on_cleanup.append(on_cleanup)
@@ -431,6 +440,44 @@ app.add_typer(
app.command(name="agent")(agent) app.command(name="agent")(agent)
# ============================================================================
# Session Commands
# ============================================================================
sessions_app = typer.Typer(help="Manage persisted session history")
app.add_typer(sessions_app, name="sessions")
@sessions_app.command("restore-workspace")
def sessions_restore_workspace(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
) -> None:
"""Copy sessions back into the workspace before downgrading nanobot."""
from nanobot.session.manager import SessionManager
runtime_config = _load_runtime_config(config, workspace)
data_dir = runtime_config.runtime_data_dir
manager = SessionManager(
runtime_config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
result = manager.restore_sessions_to_workspace()
console.print(
f"Restored {result.restored} session file(s) to "
f"{escape(str(runtime_config.workspace_path / 'sessions'))}; "
f"{result.unchanged} already matched."
)
if result.conflicts:
console.print(
"[red]Rollback is incomplete: existing or invalid files require manual review.[/red]"
)
for path in result.conflicts:
console.print(Text(f"- {path}", style="red"))
raise typer.Exit(1)
# ============================================================================ # ============================================================================
# Channel Commands # Channel Commands
# ============================================================================ # ============================================================================
+37 -15
View File
@@ -14,6 +14,8 @@ from rich.console import Console
from nanobot import __logo__, __version__ from nanobot import __logo__, __version__
from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.cli import terminal as cli_terminal from nanobot.cli import terminal as cli_terminal
from nanobot.cli.runtime_config import _migrate_cron_store from nanobot.cli.runtime_config import _migrate_cron_store
from nanobot.cli.webui_support import ( from nanobot.cli.webui_support import (
@@ -233,6 +235,7 @@ def _print_gateway_health_endpoint(host: str, port: int) -> None:
async def _close_gateway_runtime( async def _close_gateway_runtime(
agent: AgentLoop, agent: AgentLoop,
mcp_provider: MCPProvider,
channels: Any, channels: Any,
tasks: list[asyncio.Task[Any]], tasks: list[asyncio.Task[Any]],
runtime_tasks: asyncio.Future[list[Any]] | None, runtime_tasks: asyncio.Future[list[Any]] | None,
@@ -240,18 +243,13 @@ async def _close_gateway_runtime(
task_wait_timeout: float = 15.0, task_wait_timeout: float = 15.0,
close_timeout: float = 15.0, close_timeout: float = 15.0,
) -> None: ) -> None:
"""Cancel runtime tasks, then deterministically close agent resources. """Cancel runtime tasks, then deterministically close application resources.
Order matters: runtime tasks (including the agent loop and any in-flight Order matters: runtime tasks (including the agent loop and any in-flight
turn) are cancelled and awaited -- bounded -- before exec sessions, turn) are cancelled and awaited -- bounded -- before the loop-owned resources
subagents, and MCP servers are torn down, so no active turn is using a and the application-owned MCP provider are torn down. The final close is
shared resource when it closes. The final close is bounded and idempotent: bounded and idempotent, so it also covers a cancelled or incomplete loop
the agent loop's own finally also calls ``close_mcp()``, so this runs again cleanup without leaving subprocess transports alive past ``loop.close()``.
as a no-op when that path already completed, and as the guaranteed final
close when it was skipped or cut short (which previously left asyncio
subprocess transports alive past ``loop.close()``, producing
"RuntimeError: Event loop is closed" noise and potentially orphaned
processes at interpreter exit).
""" """
# Some SDKs swallow task cancellation while attempting to reconnect. # Some SDKs swallow task cancellation while attempting to reconnect.
# Close channel transports before waiting for their runners to exit. # Close channel transports before waiting for their runners to exit.
@@ -272,10 +270,14 @@ async def _close_gateway_runtime(
task.cancel() task.cancel()
if runtime_tasks is not None and not runtime_tasks.done(): if runtime_tasks is not None and not runtime_tasks.done():
runtime_tasks.cancel() runtime_tasks.cancel()
for label, close in (
("agent", agent.aclose),
("MCP provider", mcp_provider.aclose),
):
try: try:
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout) await asyncio.wait_for(close(), timeout=close_timeout)
except BaseException as exc: # noqa: BLE001 - shutdown must proceed except BaseException as exc: # noqa: BLE001 - shutdown must proceed
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc) logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
# Retrieving an already-finished gather prevents noisy unhandled exceptions, # Retrieving an already-finished gather prevents noisy unhandled exceptions,
# but never wait for it here: its children were bounded individually above. # but never wait for it here: its children were bounded individually above.
if runtime_tasks is not None and runtime_tasks.done(): if runtime_tasks is not None and runtime_tasks.done():
@@ -414,6 +416,9 @@ def _run_gateway(
route_policy=WebuiTurnRoutePolicy(session_manager), route_policy=WebuiTurnRoutePolicy(session_manager),
) )
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools)
# Create agent with cron service # Create agent with cron service
agent = AgentLoop.from_config( agent = AgentLoop.from_config(
config, bus, config, bus,
@@ -431,6 +436,7 @@ def _run_gateway(
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)], hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store, local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
) )
def _schedule_webui_background(awaitable: Awaitable[None]) -> None: def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable)) agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
@@ -512,6 +518,7 @@ def _run_gateway(
prompt, last_cursor = result prompt, last_cursor = result
key = dream_session_key() key = dream_session_key()
dream_runtime = agent.dream_runtime() dream_runtime = agent.dream_runtime()
await mcp_provider.connect()
resp = await agent.process_direct( resp = await agent.process_direct(
prompt, prompt,
session_key=key, session_key=key,
@@ -589,6 +596,7 @@ def _run_gateway(
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
suppress_token = message_tool.set_suppress_delivery(True) suppress_token = message_tool.set_suppress_delivery(True)
try: try:
await mcp_provider.connect()
resp = await agent.process_direct( resp = await agent.process_direct(
prompt, prompt,
session_key="heartbeat", session_key="heartbeat",
@@ -668,7 +676,8 @@ def _run_gateway(
webui_static_dist=webui_static_dist, webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface, webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities, webui_runtime_capabilities=webui_runtime_capabilities,
webui_mcp_runtime_status=agent.mcp_runtime_status, webui_mcp_runtime_status=mcp_provider.runtime_status,
webui_mcp_reload=mcp_provider.reload,
webui_skill_state_action=_webui_skill_state_action, webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path), config_path=Path(config_path),
) )
@@ -844,6 +853,13 @@ def _run_gateway(
await cron.start() await cron.start()
# Re-read once on first admission to close the watcher subscription window. # Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate() agent.runtime_resolver.invalidate()
async def _run_agent() -> None:
try:
await mcp_provider.connect()
await agent.run()
finally:
await mcp_provider.aclose()
tasks = [ tasks = [
asyncio.create_task( asyncio.create_task(
watch_config_file( watch_config_file(
@@ -852,7 +868,7 @@ def _run_gateway(
), ),
name="nanobot-config-watcher", name="nanobot-config-watcher",
), ),
asyncio.create_task(agent.run(), name="nanobot-agent-loop"), asyncio.create_task(_run_agent(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"), asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task( asyncio.create_task(
run_local_trigger_queue( run_local_trigger_queue(
@@ -910,7 +926,13 @@ def _run_gateway(
agent.stop() agent.stop()
# Cancel runtime tasks first, then deterministically close # Cancel runtime tasks first, then deterministically close
# exec/MCP resources while the event loop is still alive. # exec/MCP resources while the event loop is still alive.
await _close_gateway_runtime(agent, channels, tasks, runtime_tasks) await _close_gateway_runtime(
agent,
mcp_provider,
channels,
tasks,
runtime_tasks,
)
# Flush all cached sessions to durable storage before exit. # Flush all cached sessions to durable storage before exit.
# This prevents data loss on filesystems with write-back # This prevents data loss on filesystems with write-back
# caching (rclone VFS, NFS, FUSE mounts, etc.). # caching (rclone VFS, NFS, FUSE mounts, etc.).
+2
View File
@@ -75,6 +75,7 @@ def load_config(config_path: Path | None = None) -> Config:
summary="Environment-based configuration is invalid.", summary="Environment-based configuration is invalid.",
issues=validation_issues(exc), issues=validation_issues(exc),
) from exc ) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config) _apply_ssrf_whitelist(config)
return config return config
@@ -130,6 +131,7 @@ def load_config(config_path: Path | None = None) -> Config:
issues=issues, issues=issues,
) from exc ) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config) _apply_ssrf_whitelist(config)
return config return config
+12 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator from pydantic import AliasChoices, ConfigDict, Field, PrivateAttr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from nanobot.config.timezone import detect_system_timezone from nanobot.config.timezone import detect_system_timezone
@@ -431,6 +431,8 @@ class ToolsConfig(Base):
class Config(BaseSettings): class Config(BaseSettings):
"""Root configuration for nanobot.""" """Root configuration for nanobot."""
_source_path: Path | None = PrivateAttr(default=None)
agents: AgentsConfig = Field(default_factory=AgentsConfig) agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig) transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
@@ -449,6 +451,15 @@ class Config(BaseSettings):
_resolve_tool_config_refs() _resolve_tool_config_refs()
super().__init__(**values) super().__init__(**values)
def bind_source_path(self, path: Path) -> None:
"""Record the config file that owns instance-level runtime data."""
self._source_path = path.expanduser().resolve(strict=False)
@property
def runtime_data_dir(self) -> Path | None:
"""Return the active instance data directory when loaded from a config path."""
return self._source_path.parent if self._source_path is not None else None
@model_validator(mode="after") @model_validator(mode="after")
def _validate_model_preset(self) -> "Config": def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets: if "default" in self.model_presets:
+24 -4
View File
@@ -10,6 +10,8 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
@@ -71,9 +73,16 @@ class Nanobot:
print(result.content) print(result.content)
""" """
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None: def __init__(
self,
loop: AgentLoop,
*,
config: Config | None = None,
mcp_provider: MCPProvider | None = None,
) -> None:
self._loop = loop self._loop = loop
self._config = config self._config = config
self._mcp_provider = mcp_provider
self.sessions = SessionClient(loop) self.sessions = SessionClient(loop)
self.memory = MemoryClient(loop) self.memory = MemoryClient(loop)
self.runtime = RuntimeClient(loop) self.runtime = RuntimeClient(loop)
@@ -120,12 +129,15 @@ class Nanobot:
elif model_preset is not None: elif model_preset is not None:
config.agents.defaults.model_preset = model_preset config.agents.defaults.model_preset = model_preset
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools)
loop = AgentLoop.from_config( loop = AgentLoop.from_config(
config, config,
image_generation_provider_configs=image_gen_provider_configs(config), image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
) )
return cls(loop, config=config) return cls(loop, config=config, mcp_provider=mcp_provider)
async def run( async def run(
self, self,
@@ -178,6 +190,8 @@ class Nanobot:
) )
if runtime is not None: if runtime is not None:
kwargs["runtime"] = runtime kwargs["runtime"] = runtime
if self._mcp_provider is not None:
await self._mcp_provider.connect()
response = await self._loop.process_direct( response = await self._loop.process_direct(
message, message,
**kwargs, **kwargs,
@@ -259,6 +273,8 @@ class Nanobot:
if override_runtime is not None: if override_runtime is not None:
kwargs["runtime"] = override_runtime kwargs["runtime"] = override_runtime
try: try:
if self._mcp_provider is not None:
await self._mcp_provider.connect()
response = await self._loop.process_direct( response = await self._loop.process_direct(
message, message,
**kwargs, **kwargs,
@@ -327,8 +343,12 @@ class Nanobot:
await run.aclose() await run.aclose()
async def aclose(self) -> None: async def aclose(self) -> None:
"""Release resources held by this instance (MCP connections, etc.).""" """Release resources held by this instance."""
await self._loop.close_mcp() try:
await self._loop.aclose()
finally:
if self._mcp_provider is not None:
await self._mcp_provider.aclose()
async def __aenter__(self) -> Nanobot: async def __aenter__(self) -> Nanobot:
return self return self
+82 -2
View File
@@ -56,6 +56,8 @@ if TYPE_CHECKING:
# that ``unittest.mock.patch`` can find and replace it. # that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None AsyncOpenAI: Any = None
_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
def _is_hosted_web_search_type(value: object) -> bool: def _is_hosted_web_search_type(value: object) -> bool:
return isinstance(value, str) and ( return isinstance(value, str) and (
@@ -690,6 +692,8 @@ class OpenAICompatProvider(LLMProvider):
if strip_reasoning: if strip_reasoning:
for msg in sanitized: for msg in sanitized:
msg.pop("reasoning_content", None) msg.pop("reasoning_content", None)
if self._spec and self._spec.name == "gemini":
sanitized = self._ensure_gemini_thought_signatures(sanitized)
def map_id(value: Any) -> Any: def map_id(value: Any) -> Any:
if not isinstance(value, str): if not isinstance(value, str):
@@ -767,6 +771,81 @@ class OpenAICompatProvider(LLMProvider):
clean["content"] = self._coerce_content_to_string(clean.get("content")) clean["content"] = self._coerce_content_to_string(clean.get("content"))
return self._enforce_role_alternation(sanitized) return self._enforce_role_alternation(sanitized)
@staticmethod
def _gemini_thought_signature(tool_call: dict[str, Any]) -> str | None:
"""Return Gemini's thought signature attached to a tool call, if any.
Gemini's OpenAI-compatible endpoint returns tool calls with an
``extra_content`` field: ``{"google": {"thought_signature": "..."}}``.
nanobot preserves it through the parse -> serialize round-trip so
replayed calls stay valid. Calls produced by other providers (e.g.
after a mid-conversation model switch) carry no signature.
"""
extra = tool_call.get("extra_content")
if not isinstance(extra, dict):
return None
google = cast(dict[str, Any], extra).get("google")
if not isinstance(google, dict):
return None
signature = cast(dict[str, Any], google).get("thought_signature")
if isinstance(signature, str) and signature:
return signature
return None
def _ensure_gemini_thought_signatures(
self, messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Keep migrated tool history wire-valid without losing tool context.
Gemini requires the first call in each function-call step to carry a
thought signature. Native parallel calls intentionally leave later
calls unsigned, so they must remain in their original order. For a
fully unsigned step imported from another provider, Google documents
``skip_thought_signature_validator`` as a last-resort migration value.
"""
kept: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
calls = msg.get("tool_calls")
if role != "assistant" or not isinstance(calls, list) or not calls:
kept.append(msg)
continue
call_values = cast(list[object], calls)
typed_calls = [
cast(dict[str, Any], tool_call)
for tool_call in call_values
if isinstance(tool_call, dict)
]
if not typed_calls:
if msg.get("content"):
clean = dict(msg)
clean.pop("tool_calls", None)
kept.append(clean)
continue
clean_calls = typed_calls
if self._gemini_thought_signature(typed_calls[0]) is None:
first = dict(typed_calls[0])
extra_value = first.get("extra_content")
extra = dict(cast(dict[str, Any], extra_value)) if isinstance(
extra_value, dict
) else {}
google_value = extra.get("google")
google = dict(cast(dict[str, Any], google_value)) if isinstance(
google_value, dict
) else {}
google["thought_signature"] = _GEMINI_SKIP_THOUGHT_SIGNATURE
extra["google"] = google
first["extra_content"] = extra
clean_calls = [first, *typed_calls[1:]]
if clean_calls != call_values:
msg = dict(msg)
msg["tool_calls"] = clean_calls
kept.append(msg)
return kept
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Build kwargs # Build kwargs
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1158,7 +1237,8 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages) self._sanitize_empty_content(sanitized_state.pending_messages)
) )
) )
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek") is_deepseek = bool(self._spec and self._spec.name == "deepseek")
preserve_reasoning = is_deepseek
instructions, input_items, replayed = prepare_responses_input( instructions, input_items, replayed = prepare_responses_input(
sanitized_messages, sanitized_messages,
state=sanitized_state, state=sanitized_state,
@@ -1194,7 +1274,7 @@ class OpenAICompatProvider(LLMProvider):
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning: if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
body["include"] = ["reasoning.encrypted_content"] body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none": if reasoning_effort and (reasoning_effort.lower() != "none" or is_deepseek):
body["reasoning"] = {"effort": reasoning_effort} body["reasoning"] = {"effort": reasoning_effort}
if replayed and "gpt-5.6" in model_name.lower(): if replayed and "gpt-5.6" in model_name.lower():
body.setdefault("reasoning", {})["context"] = "all_turns" body.setdefault("reasoning", {})["context"] = "all_turns"
+2 -3
View File
@@ -112,8 +112,7 @@ class ProviderSpec:
implicit_reasoning_models: tuple[str, ...] = () implicit_reasoning_models: tuple[str, ...] = ()
# Models that expose the OpenAI Responses wire format. This is model-level # Models that expose the OpenAI Responses wire format. This is model-level
# because providers may add Responses support incrementally (DeepSeek V4 # because providers may add Responses support incrementally.
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = () responses_models: tuple[str, ...] = ()
# Provider-hosted Responses tools sent unless extraBody.tools explicitly # Provider-hosted Responses tools sent unless extraBody.tools explicitly
@@ -482,7 +481,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.deepseek.com", default_api_base="https://api.deepseek.com",
thinking_style="thinking_type", thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",), responses_models=("deepseek-v4-flash", "deepseek-v4-pro"),
responses_default_tools=("web_search",), responses_default_tools=("web_search",),
), ),
# Gemini: Google's OpenAI-compatible endpoint # Gemini: Google's OpenAI-compatible endpoint
+477 -5
View File
@@ -2,9 +2,12 @@
import base64 import base64
import errno import errno
import hashlib
import json import json
import os import os
import re import re
import secrets
import stat
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
@@ -14,9 +17,10 @@ from pathlib import Path
from typing import Any, Callable, Collection, Protocol, TypedDict, cast from typing import Any, Callable, Collection, Protocol, TypedDict, cast
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
from filelock import FileLock
from loguru import logger from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir from nanobot.config.paths import get_legacy_sessions_dir, get_runtime_subdir
from nanobot.providers.base import ProviderConversationState from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import ( from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META, RUNTIME_CONTEXT_HISTORY_META,
@@ -57,6 +61,11 @@ _FORK_VOLATILE_METADATA_KEYS = {
"title", "title",
"title_user_edited", "title_user_edited",
} }
_WORKSPACE_STATE_DIR = ".nanobot"
_WORKSPACE_ID_FILE = "workspace-id"
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
_COPY_CHUNK_SIZE = 1024 * 1024
def _json_object(value: object) -> dict[str, Any]: def _json_object(value: object) -> dict[str, Any]:
@@ -503,6 +512,23 @@ class SessionInfo(TypedDict):
path: str path: str
@dataclass(frozen=True)
class _SessionFileSnapshot:
digest: str
size: int
mtime_ns: int
updated_at: float
device: int
inode: int
@dataclass(frozen=True)
class SessionRestoreResult:
restored: int
unchanged: int
conflicts: tuple[Path, ...]
class SessionStore(Protocol): class SessionStore(Protocol):
def load(self, key: str) -> Session | None: ... def load(self, key: str) -> Session | None: ...
@@ -520,9 +546,445 @@ class SessionStore(Protocol):
class JsonlSessionStore: class JsonlSessionStore:
"""JSONL implementation of session persistence.""" """JSONL implementation of session persistence."""
def __init__(self, workspace: Path): def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
self.sessions_dir = ensure_dir(workspace / "sessions") canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
ensure_dir(canonical_workspace)
root = (
Path(sessions_root).expanduser().resolve(strict=False)
if sessions_root is not None
else get_runtime_subdir("sessions").resolve(strict=False)
)
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
raise RuntimeError(
"session storage must be outside the agent workspace; "
"move --config outside --workspace or choose a nested workspace directory"
)
ensure_dir(root)
with suppress(OSError):
os.chmod(root, 0o700)
self.workspace = canonical_workspace
self._migration_lock = FileLock(
str(root / ".workspace-migration.lock"),
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
)
with self._migration_lock:
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
workspace_id = self._claim_workspace_namespace(
root,
canonical_workspace,
workspace_id,
)
self.sessions_dir = ensure_dir(root / workspace_id)
self.legacy_sessions_dir = get_legacy_sessions_dir() self.legacy_sessions_dir = get_legacy_sessions_dir()
self._migrate_from_workspace(canonical_workspace)
@staticmethod
def _fsync_directory(path: Path) -> None:
with suppress(PermissionError, NotImplementedError):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
@classmethod
def _write_text_atomic(cls, path: Path, content: str, *, mode: int = 0o600) -> None:
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
try:
with open(tmp, "x", encoding="utf-8") as handle:
os.chmod(tmp, mode)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
cls._fsync_directory(path.parent)
finally:
tmp.unlink(missing_ok=True)
@classmethod
def _read_workspace_id(cls, marker: Path) -> str:
if marker.is_symlink():
raise RuntimeError(f"workspace identity marker must not be a symlink: {marker}")
value = marker.read_text(encoding="utf-8").strip()
if not _WORKSPACE_ID_RE.fullmatch(value):
raise RuntimeError(
f"workspace identity marker is invalid: {marker}; "
"restore its original 32-character identifier before starting nanobot"
)
return value
@staticmethod
def _workspace_id_path(workspace: Path) -> Path:
state_dir = workspace / _WORKSPACE_STATE_DIR
if state_dir.is_symlink():
raise RuntimeError(f"workspace state directory must not be a symlink: {state_dir}")
ensure_dir(state_dir)
return state_dir / _WORKSPACE_ID_FILE
@classmethod
def _find_workspace_namespace(cls, workspace: Path, root: Path) -> str | None:
"""Recover an identity marker removed by cleanup at the same workspace path."""
matches: list[str] = []
for sessions_dir in root.iterdir():
if (
not _WORKSPACE_ID_RE.fullmatch(sessions_dir.name)
or sessions_dir.is_symlink()
or not sessions_dir.is_dir()
):
continue
marker = sessions_dir / ".workspace"
if marker.is_symlink() or not marker.is_file():
continue
try:
recorded = Path(marker.read_text(encoding="utf-8").strip()).expanduser()
recorded = recorded.resolve(strict=False)
same_workspace = recorded == workspace or (
recorded.exists() and recorded.samefile(workspace)
)
except (OSError, UnicodeError, ValueError):
continue
if same_workspace:
matches.append(sessions_dir.name)
if len(matches) > 1:
raise RuntimeError(
f"multiple session namespaces claim workspace {workspace}; "
"remove the stale namespace marker before starting nanobot"
)
return matches[0] if matches else None
@classmethod
def _load_or_create_workspace_id(cls, workspace: Path, root: Path) -> str:
marker = cls._workspace_id_path(workspace)
if marker.exists() or marker.is_symlink():
return cls._read_workspace_id(marker)
recovered = cls._find_workspace_namespace(workspace, root)
if recovered is not None:
cls._write_text_atomic(marker, f"{recovered}\n")
return recovered
workspace_id = secrets.token_hex(16)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(marker, flags, 0o600)
except FileExistsError:
return cls._read_workspace_id(marker)
try:
payload = f"{workspace_id}\n".encode("ascii")
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
except BaseException:
with suppress(OSError):
marker.unlink()
raise
finally:
os.close(fd)
cls._fsync_directory(marker.parent)
return workspace_id
@classmethod
def _replace_workspace_id(cls, workspace: Path, workspace_id: str) -> None:
cls._write_text_atomic(cls._workspace_id_path(workspace), f"{workspace_id}\n")
@classmethod
def _write_workspace_marker(cls, sessions_dir: Path, workspace: Path) -> None:
cls._write_text_atomic(sessions_dir / ".workspace", f"{workspace}\n")
@classmethod
def _claim_workspace_namespace(
cls,
root: Path,
workspace: Path,
workspace_id: str,
) -> str:
"""Bind a stable workspace ID, rotating copied live workspaces apart."""
for _attempt in range(3):
sessions_dir = root / workspace_id
marker = sessions_dir / ".workspace"
if sessions_dir.is_symlink():
raise RuntimeError(f"session namespace must not be a symlink: {sessions_dir}")
if not sessions_dir.exists():
ensure_dir(sessions_dir)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if marker.is_symlink():
raise RuntimeError(f"session workspace marker must not be a symlink: {marker}")
if not marker.exists():
if any(sessions_dir.iterdir()):
raise RuntimeError(
f"session namespace has data but no workspace marker: {sessions_dir}"
)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
recorded_text = marker.read_text(encoding="utf-8").strip()
if not recorded_text:
raise RuntimeError(f"session workspace marker is empty: {marker}")
recorded = Path(recorded_text).expanduser().resolve(strict=False)
if recorded == workspace:
return workspace_id
try:
same_workspace = recorded.exists() and recorded.samefile(workspace)
except OSError:
same_workspace = False
if same_workspace:
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if not recorded.exists():
# The identity marker travelled with a renamed or moved workspace.
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
# Both paths exist and are different: this is a copy, not a move.
workspace_id = secrets.token_hex(16)
cls._replace_workspace_id(workspace, workspace_id)
raise RuntimeError(f"could not allocate an isolated session namespace for {workspace}")
@staticmethod
def _session_file_snapshot(path: Path) -> _SessionFileSnapshot | None:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path, flags)
except OSError:
return None
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode):
return None
digest = hashlib.sha256()
saw_record = False
updated_at: float | None = None
with os.fdopen(fd, "rb", closefd=False) as handle:
for raw_line in handle:
digest.update(raw_line)
if not raw_line.strip():
continue
value: object = json.loads(raw_line.decode("utf-8"))
data = _json_object(value)
saw_record = True
if data.get("_type") == "metadata":
raw_updated_at = cast(object, data.get("updated_at"))
if isinstance(raw_updated_at, str) and raw_updated_at:
updated_at = datetime.fromisoformat(raw_updated_at).timestamp()
after = os.fstat(fd)
if (
not saw_record
or before.st_dev != after.st_dev
or before.st_ino != after.st_ino
or before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
):
return None
return _SessionFileSnapshot(
digest=digest.hexdigest(),
size=after.st_size,
mtime_ns=after.st_mtime_ns,
updated_at=(updated_at if updated_at is not None else after.st_mtime_ns / 1e9),
device=after.st_dev,
inode=after.st_ino,
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError):
return None
finally:
os.close(fd)
@classmethod
def _prepare_copy(
cls,
src: Path,
dst_dir: Path,
snapshot: _SessionFileSnapshot,
) -> Path:
tmp = dst_dir / f".{src.name}.{secrets.token_hex(8)}.tmp"
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
src_fd = os.open(src, flags)
try:
before = os.fstat(src_fd)
if (
before.st_dev != snapshot.device
or before.st_ino != snapshot.inode
or before.st_size != snapshot.size
or before.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed before migration")
digest = hashlib.sha256()
size = 0
with os.fdopen(src_fd, "rb", closefd=False) as source, open(tmp, "xb") as target:
os.chmod(tmp, 0o600)
while chunk := source.read(_COPY_CHUNK_SIZE):
digest.update(chunk)
size += len(chunk)
target.write(chunk)
target.flush()
os.fsync(target.fileno())
after = os.fstat(src_fd)
if (
digest.hexdigest() != snapshot.digest
or size != snapshot.size
or after.st_dev != snapshot.device
or after.st_ino != snapshot.inode
or after.st_size != snapshot.size
or after.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed during migration")
return tmp
except BaseException:
tmp.unlink(missing_ok=True)
raise
finally:
os.close(src_fd)
@classmethod
def _install_snapshot(
cls,
src: Path,
dst: Path,
snapshot: _SessionFileSnapshot,
) -> None:
tmp = cls._prepare_copy(src, dst.parent, snapshot)
try:
os.replace(tmp, dst)
cls._fsync_directory(dst.parent)
installed = cls._session_file_snapshot(dst)
if installed is None or installed.digest != snapshot.digest:
raise OSError(f"session migration verification failed: {dst}")
finally:
tmp.unlink(missing_ok=True)
def _archive_conflict(
self,
src: Path,
snapshot: _SessionFileSnapshot,
label: str,
) -> Path:
conflict_dir = ensure_dir(self.sessions_dir / ".migration-conflicts")
conflict = conflict_dir / (
f"{src.stem}.{label}.{snapshot.digest[:12]}.{secrets.token_hex(4)}.jsonl"
)
self._install_snapshot(src, conflict, snapshot)
return conflict
@classmethod
def _remove_migrated_source(
cls,
src: Path,
snapshot: _SessionFileSnapshot,
) -> bool:
try:
current = src.stat(follow_symlinks=False)
if (
current.st_dev != snapshot.device
or current.st_ino != snapshot.inode
or current.st_size != snapshot.size
or current.st_mtime_ns != snapshot.mtime_ns
):
return False
src.unlink()
cls._fsync_directory(src.parent)
return True
except OSError:
return False
def _migrate_from_workspace(self, workspace: Path) -> None:
"""Durably copy legacy sessions out of the workspace, then remove the source."""
old_dir = workspace / "sessions"
if old_dir.is_symlink() or not old_dir.is_dir():
if old_dir.is_symlink():
logger.warning("Skipping symlinked legacy sessions directory: {}", old_dir)
return
for src in old_dir.glob("*.jsonl"):
if src.is_symlink() or not src.is_file():
logger.warning("Skipping unsafe legacy session file: {}", src)
continue
dst = self.sessions_dir / src.name
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
logger.warning("Skipping invalid or changing legacy session file: {}", src)
continue
try:
destination_snapshot = self._session_file_snapshot(dst) if dst.exists() else None
if dst.exists() and destination_snapshot is None:
logger.warning(
"Keeping legacy session because destination is invalid: {}",
dst,
)
continue
if destination_snapshot is None:
self._install_snapshot(src, dst, source_snapshot)
elif destination_snapshot.digest == source_snapshot.digest:
pass
elif source_snapshot.updated_at > destination_snapshot.updated_at:
archived = self._archive_conflict(dst, destination_snapshot, "destination")
self._install_snapshot(src, dst, source_snapshot)
logger.warning("Archived older session migration conflict at {}", archived)
else:
archived = self._archive_conflict(src, source_snapshot, "workspace")
logger.warning("Archived older session migration conflict at {}", archived)
installed = self._session_file_snapshot(dst)
if installed is None:
raise OSError(f"session migration destination is unreadable: {dst}")
selected_digest = (
source_snapshot.digest
if destination_snapshot is None
or source_snapshot.updated_at > destination_snapshot.updated_at
else destination_snapshot.digest
)
if installed.digest != selected_digest:
raise OSError(f"session migration selected unexpected data: {dst}")
if not self._remove_migrated_source(src, source_snapshot):
logger.warning(
"Session migrated but legacy source changed or could not be removed: {}",
src,
)
except OSError as exc:
logger.warning("Failed to migrate session {}: {}", src, exc)
def restore_to_workspace(self) -> SessionRestoreResult:
"""Copy canonical sessions back for an explicit downgrade or rollback."""
restored = 0
unchanged = 0
conflicts: list[Path] = []
old_dir = self.workspace / "sessions"
if old_dir.is_symlink():
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
ensure_dir(old_dir)
with self._migration_lock:
for src in self.sessions_dir.glob("*.jsonl"):
if self.session_key_from_path(src) is None:
continue
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
conflicts.append(src)
continue
dst = old_dir / src.name
if dst.exists():
destination_snapshot = self._session_file_snapshot(dst)
if (
destination_snapshot is not None
and destination_snapshot.digest == source_snapshot.digest
):
unchanged += 1
else:
conflicts.append(dst)
continue
self._install_snapshot(src, dst, source_snapshot)
restored += 1
return SessionRestoreResult(
restored=restored,
unchanged=unchanged,
conflicts=tuple(conflicts),
)
@staticmethod @staticmethod
def safe_key(key: str) -> str: def safe_key(key: str) -> str:
@@ -991,9 +1453,15 @@ class JsonlSessionStore:
class SessionManager: class SessionManager:
"""Manage session identity, caching, retention, and persistence.""" """Manage session identity, caching, retention, and persistence."""
def __init__(self, workspace: Path, *, store: SessionStore | None = None): def __init__(
self,
workspace: Path,
*,
store: SessionStore | None = None,
sessions_root: Path | None = None,
):
self.workspace = workspace self.workspace = workspace
self._jsonl_store = JsonlSessionStore(workspace) self._jsonl_store = JsonlSessionStore(workspace, sessions_root=sessions_root)
self._store: SessionStore = store if store is not None else self._jsonl_store self._store: SessionStore = store if store is not None else self._jsonl_store
self.sessions_dir = self._jsonl_store.sessions_dir self.sessions_dir = self._jsonl_store.sessions_dir
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
@@ -1159,6 +1627,10 @@ class SessionManager:
self.invalidate(key) self.invalidate(key)
return self._store.delete(key) return self._store.delete(key)
def restore_sessions_to_workspace(self) -> SessionRestoreResult:
"""Restore session files to the pre-relocation path for an explicit rollback."""
return self._jsonl_store.restore_to_workspace()
def fork_session_before_user_index( def fork_session_before_user_index(
self, self,
source_key: str, source_key: str,
+3 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Awaitable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
@@ -66,6 +66,7 @@ def build_gateway_services(
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None, skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
@@ -119,6 +120,7 @@ def build_gateway_services(
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status, mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload,
skill_state_action=skill_state_action, skill_state_action=skill_state_action,
log=logger, log=logger,
) )
+3
View File
@@ -100,6 +100,7 @@ def http_json_response(
*, *,
status: int = 200, status: int = 200,
accept_encoding: str | None = None, accept_encoding: str | None = None,
extra_headers: list[tuple[str, str]] | None = None,
) -> Response: ) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = [ headers = [
@@ -112,6 +113,8 @@ def http_json_response(
if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding): if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding):
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0) body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
headers.append(("Content-Encoding", "gzip")) headers.append(("Content-Encoding", "gzip"))
if extra_headers:
headers.extend(extra_headers)
headers.append(("Content-Length", str(len(body)))) headers.append(("Content-Length", str(len(body))))
reason = http.HTTPStatus(status).phrase reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body) return Response(status, reason, Headers(headers), body)
+4 -4
View File
@@ -56,7 +56,6 @@ _MCP_ATTACHMENT_KEYS = (
"status", "status",
"configured", "configured",
) )
_MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20 _DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30 _DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"} _CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
@@ -1097,7 +1096,7 @@ async def mcp_presets_test_action(
*, *,
config_path: Path | None = None, config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Connect to an enabled MCP preset and report its tool surface.""" """Connect to an enabled MCP preset and report its complete tool surface."""
from nanobot.agent.tools.mcp import connect_mcp_servers from nanobot.agent.tools.mcp import connect_mcp_servers
name = (_query_first(query, "name") or "").strip() name = (_query_first(query, "name") or "").strip()
@@ -1157,9 +1156,10 @@ async def mcp_presets_test_action(
registry = ToolRegistry() registry = ToolRegistry()
stacks: dict[str, Any] = {} stacks: dict[str, Any] = {}
inspection_cfg = cfg.model_copy(update={"enabled_tools": ["*"]})
try: try:
stacks = await asyncio.wait_for( stacks = await asyncio.wait_for(
connect_mcp_servers({name: cfg}, registry), connect_mcp_servers({name: inspection_cfg}, registry),
timeout=_test_timeout(cfg), timeout=_test_timeout(cfg),
) )
tool_prefix = f"mcp_{name}_" tool_prefix = f"mcp_{name}_"
@@ -1178,7 +1178,7 @@ async def mcp_presets_test_action(
else f"{display_name} connected, but reported no tools." else f"{display_name} connected, but reported no tools."
), ),
"tool_count": len(tool_names), "tool_count": len(tool_names),
"tool_names": tool_names[:_MAX_TEST_TOOLS], "tool_names": tool_names,
"checked_at": _checked_at(), "checked_at": _checked_at(),
} }
else: else:
+292 -23
View File
@@ -1,14 +1,15 @@
"""Cache-only WebUI session list index. """Cache-only WebUI session list index.
The core ``SessionManager`` owns durable conversation history. This module owns The core ``SessionManager`` owns model context while the WebUI transcript owns
the WebUI sidebar optimization so core session writes stay independent from UI durable display history. The sidebar discovers both without reconstructing one
presentation caches. store from the other, so core session writes stay independent from UI state.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -30,9 +31,12 @@ from nanobot.session.manager import (
) )
from nanobot.session.model_selection import model_preset_from_metadata from nanobot.session.model_selection import model_preset_from_metadata
_INDEX_VERSION = 6 _INDEX_VERSION = 7
_INDEX_FILENAME = ".webui_session_index.json" _INDEX_FILENAME = ".webui_session_index.json"
_MODEL_PRESET_FIELD = "model_preset" _MODEL_PRESET_FIELD = "model_preset"
_ROW_SOURCE_FIELD = "_source"
_SESSION_SOURCE = "session"
_TRANSCRIPT_SOURCE = "webui_transcript"
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present" _WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value" _WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset( WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
@@ -42,7 +46,12 @@ _INDEXED_WORKSPACE_SCOPE_KEYS = ("project_path", "path", "access_mode")
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096 _MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns" _WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
_WEBUI_ACTIVITY_SIZE = "webui_activity_size" _WEBUI_ACTIVITY_SIZE = "webui_activity_size"
_WEBUI_ACTIVITY_FILES = "webui_activity_files"
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"} _VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
_WEBUI_SESSION_STEM_PREFIX = SessionManager.safe_key("websocket:")
_WEBUI_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
_TRANSCRIPT_SEGMENTS_SUFFIX = ".segments"
_TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]: def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
@@ -53,41 +62,79 @@ def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]
_write_index_rows(session_manager.sessions_dir, rows) _write_index_rows(session_manager.sessions_dir, rows)
except Exception as e: except Exception as e:
logger.debug("Failed to write WebUI session list index: {}", e) logger.debug("Failed to write WebUI session list index: {}", e)
sessions = [_public_row(session_manager.sessions_dir, row) for row in rows] sessions = [
_public_row(session_manager.sessions_dir, get_webui_dir(), row)
for row in rows
]
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True) return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]: def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
existing_rows = _read_index_rows(session_manager.sessions_dir) existing_rows = _read_index_rows(session_manager.sessions_dir)
existing_by_file = { existing_by_source = {
row.get("file"): row (row.get(_ROW_SOURCE_FIELD), row.get("file")): row
for row in existing_rows or [] for row in existing_rows or []
if isinstance(row.get("file"), str) if isinstance(row.get(_ROW_SOURCE_FIELD), str)
and isinstance(row.get("file"), str)
} }
paths = sorted(
path
for path in session_manager.sessions_dir.glob("*.jsonl")
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
)
if not paths:
return [], existing_rows != []
webui_dir = get_webui_dir() webui_dir = get_webui_dir()
session_paths: dict[str, Path] = {}
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
if key is not None:
session_paths[key] = path
session_keys_by_stem = {
SessionManager.safe_key(key): key
for key in session_paths
if key.startswith("websocket:")
}
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
changed = existing_rows is None changed = existing_rows is None
expected_sources: set[tuple[str, str]] = set()
for path in paths: for key, path in sorted(session_paths.items()):
row = existing_by_file.get(path.name) identity = (_SESSION_SOURCE, path.name)
row = existing_by_source.get(identity)
if row is not None and _indexed_row_matches_file(row, path, webui_dir): if row is not None and _indexed_row_matches_file(row, path, webui_dir):
rows.append(row) rows.append(row)
expected_sources.add(identity)
continue continue
changed = True changed = True
scanned = _scan_session_row(session_manager, path, webui_dir) scanned = _scan_session_row(session_manager, path, webui_dir)
if scanned is not None: if scanned is not None:
rows.append(scanned) rows.append(scanned)
expected_sources.add(identity)
if set(existing_by_file) != {path.name for path in paths}: for stem, paths in _webui_transcript_sources(webui_dir).items():
if stem in session_keys_by_stem:
continue
identity = (_TRANSCRIPT_SOURCE, stem)
row = existing_by_source.get(identity)
cached_key = row.get("key") if row is not None else None
key = (
cached_key
if isinstance(cached_key, str) and _valid_transcript_session_key(cached_key, stem)
else None
)
if key is not None and row is not None and _indexed_transcript_row_matches(
row,
key,
webui_dir,
):
rows.append(row)
expected_sources.add(identity)
continue
changed = True
scanned = _scan_transcript_row(key, stem, paths, webui_dir)
scanned_key = scanned.get("key") if scanned is not None else None
if scanned is not None and scanned_key not in session_paths:
rows.append(scanned)
expected_sources.add(identity)
if set(existing_by_source) != expected_sources:
changed = True changed = True
if existing_rows is not None and rows != existing_rows: if existing_rows is not None and rows != existing_rows:
changed = True changed = True
@@ -144,7 +191,7 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
return False return False
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool): if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
return False return False
if row.get("file") != path.name: if row.get(_ROW_SOURCE_FIELD) != _SESSION_SOURCE or row.get("file") != path.name:
return False return False
try: try:
signature = _file_signature(path) signature = _file_signature(path)
@@ -156,10 +203,39 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
and row.get("size") == signature["size"] and row.get("size") == signature["size"]
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS] and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE] and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
and row.get(_WEBUI_ACTIVITY_FILES) == activity_signature[_WEBUI_ACTIVITY_FILES]
) )
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]: def _indexed_transcript_row_matches(
row: dict[str, Any],
session_key: str,
webui_dir: Path,
) -> bool:
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
return False
if row.get(_ROW_SOURCE_FIELD) != _TRANSCRIPT_SOURCE:
return False
if row.get("key") != session_key or row.get("file") != SessionManager.safe_key(session_key):
return False
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
return False
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
return False
signature = _webui_activity_signature(session_key, webui_dir)
return (
row.get(_WEBUI_ACTIVITY_MTIME_NS) == signature[_WEBUI_ACTIVITY_MTIME_NS]
and row.get(_WEBUI_ACTIVITY_SIZE) == signature[_WEBUI_ACTIVITY_SIZE]
and row.get(_WEBUI_ACTIVITY_FILES) == signature[_WEBUI_ACTIVITY_FILES]
)
def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
file = str(row.get("file", ""))
if row.get(_ROW_SOURCE_FIELD) == _TRANSCRIPT_SOURCE:
path = webui_dir / f"{file}.jsonl"
else:
path = sessions_dir / file
return { return {
"key": row.get("key"), "key": row.get("key"),
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
@@ -169,7 +245,7 @@ def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD), _MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False), _WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD), _WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
"path": str(sessions_dir / str(row.get("file", ""))), "path": str(path),
} }
@@ -242,17 +318,90 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
return fallback_preview return fallback_preview
def _webui_transcript_record_paths(stem: str, webui_dir: Path) -> tuple[Path, ...]:
paths: list[Path] = []
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
if segments_dir.is_dir() and not segments_dir.is_symlink():
try:
paths.extend(
sorted(
path
for path in segments_dir.glob("*.jsonl")
if path.is_file() and not path.is_symlink()
)
)
except OSError:
pass
active = webui_dir / f"{stem}.jsonl"
if active.is_file() and not active.is_symlink():
paths.append(active)
return tuple(paths)
def _webui_transcript_sources(webui_dir: Path) -> dict[str, tuple[Path, ...]]:
stems: set[str] = set()
try:
entries = tuple(webui_dir.iterdir())
except OSError:
return {}
for path in entries:
if path.is_symlink():
continue
if path.is_file() and path.suffix == ".jsonl":
stem = path.stem
elif path.is_dir() and path.name.endswith(_TRANSCRIPT_SEGMENTS_SUFFIX):
stem = path.name.removesuffix(_TRANSCRIPT_SEGMENTS_SUFFIX)
else:
continue
if stem.startswith(_WEBUI_SESSION_STEM_PREFIX):
stems.add(stem)
return {
stem: paths
for stem in sorted(stems)
if (paths := _webui_transcript_record_paths(stem, webui_dir))
}
def _transcript_record(line: str) -> dict[str, Any] | None:
try:
value: object = json.loads(line)
except json.JSONDecodeError:
return None
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _valid_transcript_session_key(key: str, stem: str) -> bool:
if not key.startswith("websocket:"):
return False
chat_id = key.split(":", 1)[1]
return _WEBUI_CHAT_ID_RE.fullmatch(chat_id) is not None and SessionManager.safe_key(key) == stem
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]: def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
stem = SessionManager.safe_key(session_key) stem = SessionManager.safe_key(session_key)
return [ paths = [
webui_dir / f"{stem}.jsonl", webui_dir / f"{stem}.jsonl",
webui_dir / f"{stem}.json", webui_dir / f"{stem}.json",
] ]
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
if segments_dir.is_dir() and not segments_dir.is_symlink():
try:
paths.extend(
sorted(
path
for path in segments_dir.iterdir()
if path.is_file() and not path.is_symlink()
)
)
except OSError:
pass
return paths
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]: def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
latest_mtime_ns = 0 latest_mtime_ns = 0
total_size = 0 total_size = 0
file_count = 0
for path in _webui_activity_paths(session_key, webui_dir): for path in _webui_activity_paths(session_key, webui_dir):
try: try:
stat = path.stat() stat = path.stat()
@@ -260,11 +409,13 @@ def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, in
continue continue
if not path.is_file(): if not path.is_file():
continue continue
file_count += 1
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns) latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
total_size += stat.st_size total_size += stat.st_size
return { return {
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns, _WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
_WEBUI_ACTIVITY_SIZE: total_size, _WEBUI_ACTIVITY_SIZE: total_size,
_WEBUI_ACTIVITY_FILES: file_count,
} }
@@ -333,6 +484,7 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
"preview": _preview_from_messages(session.messages), "preview": _preview_from_messages(session.messages),
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata), _MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
**_indexed_workspace_scope_fields(session.metadata), **_indexed_workspace_scope_fields(session.metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name, "file": path.name,
"mtime_ns": signature["mtime_ns"], "mtime_ns": signature["mtime_ns"],
"size": signature["size"], "size": signature["size"],
@@ -340,6 +492,122 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
} }
def _transcript_preview(record: dict[str, Any]) -> tuple[str, str]:
text = record.get("text")
if not isinstance(text, str) or not text.strip():
return "", ""
preview = _message_preview_text({"content": text})
if not preview:
return "", ""
event = record.get("event")
if event == "user" or record.get("role") == "user":
return preview, ""
if (
event == "message"
and record.get("kind") not in _TRANSCRIPT_NON_ANSWER_KINDS
) or record.get("role") == "assistant":
return "", preview
return "", ""
def _transcript_created_at(record: dict[str, Any]) -> str | None:
value = record.get("created_at_ms")
if (
not isinstance(value, int | float)
or isinstance(value, bool)
or value < 0
):
return None
try:
return datetime.fromtimestamp(value / 1000).isoformat()
except (OSError, OverflowError, ValueError):
return None
def _scan_transcript_row(
session_key: str | None,
stem: str,
paths: tuple[Path, ...],
webui_dir: Path,
) -> dict[str, Any] | None:
path_key = session_key or f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
signature = _webui_activity_signature(path_key, webui_dir)
activity_updated_at = _webui_activity_updated_at(signature)
if activity_updated_at is None:
return None
preview = ""
fallback_preview = ""
created_at: str | None = None
saw_record = False
scanned_records = 0
scanned_chars = 0
for path in paths:
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
if not line.strip():
continue
scanned_records += 1
scanned_chars += len(line)
record = _transcript_record(line)
if record is not None:
saw_record = True
chat_id = record.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
candidate = f"websocket:{chat_id.strip()}"
if _valid_transcript_session_key(candidate, stem):
session_key = candidate
if created_at is None:
created_at = _transcript_created_at(record)
user_preview, assistant_preview = _transcript_preview(record)
if user_preview:
preview = user_preview
break
if not fallback_preview and assistant_preview:
fallback_preview = assistant_preview
if (
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
except OSError:
continue
if preview or (
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
if not saw_record:
return None
if session_key is None:
fallback = f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
if not _valid_transcript_session_key(fallback, stem):
return None
session_key = fallback
if created_at is None:
try:
earliest_mtime = min(path.stat().st_mtime for path in paths)
created_at = datetime.fromtimestamp(earliest_mtime).isoformat()
except (OSError, OverflowError, ValueError):
created_at = activity_updated_at
return {
"key": session_key,
"created_at": created_at,
"updated_at": activity_updated_at,
"title": "",
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: None,
**_indexed_workspace_scope_fields({}),
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
"file": stem,
"mtime_ns": signature[_WEBUI_ACTIVITY_MTIME_NS],
"size": signature[_WEBUI_ACTIVITY_SIZE],
**signature,
}
def _scan_session_row( def _scan_session_row(
session_manager: SessionManager, session_manager: SessionManager,
path: Path, path: Path,
@@ -418,6 +686,7 @@ def _scan_session_row(
"preview": preview or fallback_preview, "preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata), _MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
**_indexed_workspace_scope_fields(metadata), **_indexed_workspace_scope_fields(metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name, "file": path.name,
"mtime_ns": signature["mtime_ns"], "mtime_ns": signature["mtime_ns"],
"size": signature["size"], "size": signature["size"],
+33 -4
View File
@@ -5,14 +5,13 @@ from __future__ import annotations
import asyncio import asyncio
import html import html
import json import json
from collections.abc import Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast from typing import Any, cast
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
from nanobot.api.runtime import ApiRuntime, api_runtime_paths from nanobot.api.runtime import ApiRuntime, api_runtime_paths
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -71,6 +70,7 @@ _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024 _MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
_MCP_RELOAD_TIMEOUT_SECONDS = 15.0
_query_first = contracts.query_first _query_first = contracts.query_first
@@ -227,6 +227,7 @@ class WebUISettingsRouter:
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None, mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None: ) -> None:
self.settings = settings self.settings = settings
@@ -241,6 +242,7 @@ class WebUISettingsRouter:
self._channel_feature_action = channel_feature_action self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status self._channel_runtime_status = channel_runtime_status
self._mcp_runtime_status = mcp_runtime_status self._mcp_runtime_status = mcp_runtime_status
self._mcp_reload = mcp_reload
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager() self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set() self._restart_sections: set[str] = set()
@@ -472,7 +474,7 @@ class WebUISettingsRouter:
approve_code=approve_code, approve_code=approve_code,
deny_code=deny_code, deny_code=deny_code,
mcp_presets_action=mcp_presets_settings_action, mcp_presets_action=mcp_presets_settings_action,
reload_mcp=lambda: request_mcp_reload(self.bus), reload_mcp=self._reload_mcp_runtime,
mcp_runtime_status=self._mcp_runtime_status, mcp_runtime_status=self._mcp_runtime_status,
check_for_update=check_for_update, check_for_update=check_for_update,
channel_feature_action=self._channel_feature_action, channel_feature_action=self._channel_feature_action,
@@ -499,6 +501,33 @@ class WebUISettingsRouter:
self._restart_sections.discard("image") self._restart_sections.discard("image")
return updated return updated
async def _reload_mcp_runtime(self) -> dict[str, Any]:
if self._mcp_reload is None:
return {
"ok": False,
"message": "MCP runtime reload is unavailable. Restart nanobot to apply changes.",
"requires_restart": True,
}
try:
return await asyncio.wait_for(
self._mcp_reload(),
timeout=_MCP_RELOAD_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
except Exception as exc:
self.logger.exception("MCP hot reload failed")
return {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request) return self._query(request)
@@ -622,7 +651,7 @@ class WebUISettingsRouter:
name, name,
cfg, cfg,
redirect_uri, redirect_uri,
reload_mcp=lambda: request_mcp_reload(self.bus), reload_mcp=self._reload_mcp_runtime,
reset_credentials=reset, reset_credentials=reset,
) )
except Exception as exc: except Exception as exc:
+75 -1
View File
@@ -8,7 +8,9 @@ does not modify agent sessions.
from __future__ import annotations from __future__ import annotations
import json import json
import math
import os import os
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -24,8 +26,11 @@ _MAX_MAP_ITEMS = 2_000
_MAX_KEY_LEN = 512 _MAX_KEY_LEN = 512
_MAX_TITLE_LEN = 160 _MAX_TITLE_LEN = 160
_MAX_TAG_LEN = 40 _MAX_TAG_LEN = 40
_MAX_WORKBENCH_PANES = 4
_ALLOWED_DENSITIES = {"comfortable", "compact"} _ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"} _ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
def webui_sidebar_state_path() -> Path: def webui_sidebar_state_path() -> Path:
@@ -42,6 +47,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"project_name_overrides": {}, "project_name_overrides": {},
"tags_by_key": {}, "tags_by_key": {},
"collapsed_groups": {}, "collapsed_groups": {},
"workbench": {"version": 1, "tabs": {}},
"view": { "view": {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
@@ -76,6 +82,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
return out return out
def _clean_split_ratios(value: Any) -> list[float]:
if not isinstance(value, list):
return []
ratios: list[float] = []
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
continue
ratio = float(raw_ratio)
if not math.isfinite(ratio):
continue
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
return ratios
def _clean_bool_map(value: Any) -> dict[str, bool]: def _clean_bool_map(value: Any) -> dict[str, bool]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {} return {}
@@ -131,8 +151,56 @@ def _clean_view(value: Any) -> dict[str, Any]:
} }
def _clean_workbench(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {"version": 1, "tabs": {}}
workbench = cast(dict[str, Any], value)
if workbench.get("version") != 1:
return {"version": 1, "tabs": {}}
raw_tabs = workbench.get("tabs")
if not isinstance(raw_tabs, dict):
return {"version": 1, "tabs": {}}
tabs: dict[str, dict[str, Any]] = {}
claimed_panes: set[str] = set()
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
tab_key = _clean_string(raw_tab_key)
if tab_key is None or not isinstance(raw_tab, dict):
continue
tab = cast(dict[str, Any], raw_tab)
pane_keys = [
key
for key in _clean_string_list(tab.get("paneKeys"))
if key not in claimed_panes
][:_MAX_WORKBENCH_PANES]
if not pane_keys:
continue
explicit = tab.get("explicit") is True
if not explicit and len(pane_keys) == 1:
continue
requested_layout_pane_keys = [
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
]
layout_pane_keys = requested_layout_pane_keys + [
key for key in pane_keys if key not in requested_layout_pane_keys
]
claimed_panes.update(pane_keys)
raw_layout = tab.get("layout")
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
tabs[tab_key] = {
"explicit": explicit,
"title": title,
"paneKeys": pane_keys,
"layoutPaneKeys": layout_pane_keys,
"layout": layout,
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
}
return {"version": 1, "tabs": tabs}
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]: def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
"""Return a schema-v1 sidebar state from any older/partial input.""" """Return a validated canonical sidebar state."""
if not isinstance(raw, dict): if not isinstance(raw, dict):
raw = {} raw = {}
raw = cast(dict[str, Any], raw) raw = cast(dict[str, Any], raw)
@@ -146,6 +214,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
) )
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key")) state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["workbench"] = _clean_workbench(raw.get("workbench"))
state["view"] = _clean_view(raw.get("view")) state["view"] = _clean_view(raw.get("view"))
updated_at = raw.get("updated_at") updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None state["updated_at"] = updated_at if isinstance(updated_at, str) else None
@@ -169,6 +238,11 @@ def read_webui_sidebar_state() -> dict[str, Any]:
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
with _SIDEBAR_STATE_WRITE_LOCK:
return _write_webui_sidebar_state(raw)
def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_webui_sidebar_state(raw) state = normalize_webui_sidebar_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps( encoded = json.dumps(
+19 -8
View File
@@ -14,7 +14,7 @@ import json
import mimetypes import mimetypes
import re import re
import time import time
from collections.abc import Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote, urlsplit, urlunsplit from urllib.parse import quote, unquote, urlsplit, urlunsplit
@@ -121,6 +121,7 @@ from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000 _SLOW_WEBUI_HTTP_LOG_MS = 1_000
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_NO_STORE_HEADERS = [("Cache-Control", "no-store")]
_WEBUI_MUTATION_PATHS = { _WEBUI_MUTATION_PATHS = {
"automation.enable": "/api/webui/automations/enable", "automation.enable": "/api/webui/automations/enable",
@@ -308,6 +309,7 @@ class GatewayHTTPHandler:
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None, skill_state_action: Callable[[set[str]], None] | None = None,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
@@ -351,6 +353,7 @@ class GatewayHTTPHandler:
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status, mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri, mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
) )
@@ -545,9 +548,16 @@ class GatewayHTTPHandler:
"too many outstanding issued tokens ({}), rejecting issuance", "too many outstanding issued tokens ({}), rejecting issuance",
len(self.tokens.issued_tokens), len(self.tokens.issued_tokens),
) )
return _http_json_response({"error": "too many outstanding tokens"}, status=429) return _http_json_response(
{"error": "too many outstanding tokens"},
status=429,
extra_headers=_NO_STORE_HEADERS,
)
token_value = self.tokens.issue_token(self.config.token_ttl_s) token_value = self.tokens.issue_token(self.config.token_ttl_s)
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s)) return _http_json_response(
token_response_payload(token_value, self.config.token_ttl_s),
extra_headers=_NO_STORE_HEADERS,
)
# -- Bootstrap ---------------------------------------------------------- # -- Bootstrap ----------------------------------------------------------
@@ -577,7 +587,7 @@ class GatewayHTTPHandler:
"runtime_surface": self._runtime_surface, "runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities, "runtime_capabilities": self._capabilities,
} }
return _http_json_response(payload) return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
api_token_allowed = bool(secret) or is_local_browser api_token_allowed = bool(secret) or is_local_browser
if not self.tokens.can_issue(include_api_token=api_token_allowed): if not self.tokens.can_issue(include_api_token=api_token_allowed):
@@ -585,6 +595,7 @@ class GatewayHTTPHandler:
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429, status=429,
content_type="application/json; charset=utf-8", content_type="application/json; charset=utf-8",
extra_headers=_NO_STORE_HEADERS,
) )
token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui") token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui")
api_token = ( api_token = (
@@ -609,7 +620,7 @@ class GatewayHTTPHandler:
} }
if api_token is not None: if api_token is not None:
payload["api_token"] = api_token payload["api_token"] = api_token
return _http_json_response(payload) return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
def _bootstrap_ws_url(self, request: Any) -> str: def _bootstrap_ws_url(self, request: Any) -> str:
headers = getattr(request, "headers", {}) or {} headers = getattr(request, "headers", {}) or {}
@@ -844,9 +855,9 @@ class GatewayHTTPHandler:
self.local_trigger_store.delete(job.id) self.local_trigger_store.delete(job.id)
elif self.cron_service is not None: elif self.cron_service is not None:
self.cron_service.remove_job(job.id) self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key) session_deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key) transcript_deleted = delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)}) return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
# -- Automation routes -------------------------------------------------- # -- Automation routes --------------------------------------------------
-3
View File
@@ -46,7 +46,6 @@ def make_loop(
context_window_tokens: int = 128_000, context_window_tokens: int = 128_000,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
unified_session: bool = False, unified_session: bool = False,
mcp_servers: dict | None = None,
tools_config=None, tools_config=None,
model_presets: dict | None = None, model_presets: dict | None = None,
hooks: list | None = None, hooks: list | None = None,
@@ -72,8 +71,6 @@ def make_loop(
session_ttl_minutes=session_ttl_minutes, session_ttl_minutes=session_ttl_minutes,
unified_session=unified_session, unified_session=unified_session,
) )
if mcp_servers is not None:
kwargs["mcp_servers"] = mcp_servers
if tools_config is not None: if tools_config is not None:
kwargs["tools_config"] = tools_config kwargs["tools_config"] = tools_config
if model_presets is not None: if model_presets is not None:
+46 -41
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext from nanobot.command import CommandContext
@@ -193,7 +194,11 @@ class TestIdleScanThrottling:
}) })
provider = MagicMock() provider = MagicMock()
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
loop = AgentLoop.from_config(config, provider=provider) loop = AgentLoop.from_config(
config,
tool_registry=ToolRegistry(),
provider=provider,
)
loop.auto_compact.check_expired = MagicMock() loop.auto_compact.check_expired = MagicMock()
loop._check_expired_sessions_if_due() loop._check_expired_sessions_if_due()
@@ -310,7 +315,7 @@ class TestAutoCompact:
assert loop.auto_compact._is_expired(ts) is True assert loop.auto_compact._is_expired(ts) is True
ts2 = datetime.now() - timedelta(minutes=14, seconds=59) ts2 = datetime.now() - timedelta(minutes=14, seconds=59)
assert loop.auto_compact._is_expired(ts2) is False assert loop.auto_compact._is_expired(ts2) is False
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_is_expired_string_timestamp(self, tmp_path): async def test_is_expired_string_timestamp(self, tmp_path):
@@ -320,7 +325,7 @@ class TestAutoCompact:
assert loop.auto_compact._is_expired(ts) is True assert loop.auto_compact._is_expired(ts) is True
assert loop.auto_compact._is_expired(None) is False assert loop.auto_compact._is_expired(None) is False
assert loop.auto_compact._is_expired("") is False assert loop.auto_compact._is_expired("") is False
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_expired_only_archives_expired_sessions(self, tmp_path): async def test_check_expired_only_archives_expired_sessions(self, tmp_path):
@@ -343,7 +348,7 @@ class TestAutoCompact:
active_after = loop.sessions.get_or_create("cli:active") active_after = loop.sessions.get_or_create("cli:active")
assert len(active_after.messages) == 1 assert len(active_after.messages) == 1
assert active_after.messages[0]["content"] == "recent" assert active_after.messages[0]["content"] == "recent"
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path): async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
@@ -367,7 +372,7 @@ class TestAutoCompact:
assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
assert visible[0]["content"] == "msg user 2" assert visible[0]["content"] == "msg user 2"
assert visible[-1]["content"] == "msg assistant 5" assert visible[-1]["content"] == "msg assistant 5"
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path): async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path):
@@ -398,7 +403,7 @@ class TestAutoCompact:
for m in visible for m in visible
for tc in (m.get("tool_calls") or []) for tc in (m.get("tool_calls") or [])
) )
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_stores_summary(self, tmp_path): async def test_auto_compact_stores_summary(self, tmp_path):
@@ -422,7 +427,7 @@ class TestAutoCompact:
assert len(session_after.get_history(max_messages=12)) == ( assert len(session_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES loop.auto_compact._RECENT_SUFFIX_MESSAGES
) )
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_empty_session(self, tmp_path): async def test_auto_compact_empty_session(self, tmp_path):
@@ -436,7 +441,7 @@ class TestAutoCompact:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0 assert len(session_after.messages) == 0
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_respects_last_consolidated(self, tmp_path): async def test_auto_compact_respects_last_consolidated(self, tmp_path):
@@ -455,7 +460,7 @@ class TestAutoCompact:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime()) await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert len(archived_messages) == 10 assert len(archived_messages) == 10
await loop.close_mcp() await loop.aclose()
class TestAutoCompactIdleDetection: class TestAutoCompactIdleDetection:
@@ -474,7 +479,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "old message" for m in session_after.messages) assert any(m["content"] == "old message" for m in session_after.messages)
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_triggers_on_idle(self, tmp_path): async def test_auto_compact_triggers_on_idle(self, tmp_path):
@@ -503,7 +508,7 @@ class TestAutoCompactIdleDetection:
for m in session_after.get_history(max_messages=len(session_after.messages)) for m in session_after.get_history(max_messages=len(session_after.messages))
) )
assert any(m["content"] == "new msg" for m in session_after.messages) assert any(m["content"] == "new msg" for m in session_after.messages)
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_auto_compact_when_active(self, tmp_path): async def test_no_auto_compact_when_active(self, tmp_path):
@@ -517,7 +522,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "recent message" for m in session_after.messages) assert any(m["content"] == "recent message" for m in session_after.messages)
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_does_not_affect_priority_commands(self, tmp_path): async def test_auto_compact_does_not_affect_priority_commands(self, tmp_path):
@@ -540,7 +545,7 @@ class TestAutoCompactIdleDetection:
# Session should be untouched since priority commands skip _process_message # Session should be untouched since priority commands skip _process_message
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "old message" for m in session_after.messages) assert any(m["content"] == "old message" for m in session_after.messages)
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_with_slash_new(self, tmp_path): async def test_auto_compact_with_slash_new(self, tmp_path):
@@ -562,7 +567,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0 assert len(session_after.messages) == 0
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shortcut_command_persisted_with_command_flag(self, tmp_path): async def test_shortcut_command_persisted_with_command_flag(self, tmp_path):
@@ -581,7 +586,7 @@ class TestAutoCompactIdleDetection:
assert session_after.messages[1]["role"] == "assistant" assert session_after.messages[1]["role"] == "assistant"
assert session_after.messages[1].get("_command") is True assert session_after.messages[1].get("_command") is True
assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shortcut_command_excluded_from_get_history(self, tmp_path): async def test_shortcut_command_excluded_from_get_history(self, tmp_path):
@@ -597,7 +602,7 @@ class TestAutoCompactIdleDetection:
assert len(history) == 2 assert len(history) == 2
assert all(m["content"] != "/help" for m in history) assert all(m["content"] != "/help" for m in history)
assert all(m["content"] != "help text" for m in history) assert all(m["content"] != "help text" for m in history)
await loop.close_mcp() await loop.aclose()
class TestAutoCompactSystemMessages: class TestAutoCompactSystemMessages:
@@ -628,7 +633,7 @@ class TestAutoCompactSystemMessages:
m["content"] == "old user 0" m["content"] == "old user 0"
for m in session_after.get_history(max_messages=len(session_after.messages)) for m in session_after.get_history(max_messages=len(session_after.messages))
) )
await loop.close_mcp() await loop.aclose()
class TestAutoCompactEdgeCases: class TestAutoCompactEdgeCases:
@@ -656,7 +661,7 @@ class TestAutoCompactEdgeCases:
# "(nothing)" summary should not be stored # "(nothing)" summary should not be stored
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path): async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
@@ -677,7 +682,7 @@ class TestAutoCompactEdgeCases:
loop.auto_compact._RECENT_SUFFIX_MESSAGES loop.auto_compact._RECENT_SUFFIX_MESSAGES
) )
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path): async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
@@ -709,7 +714,7 @@ class TestAutoCompactEdgeCases:
assert any(m["content"] == "previous message" for m in session_after.messages) assert any(m["content"] == "previous message" for m in session_after.messages)
assert any(m["content"] == "interrupted response" for m in session_after.messages) assert any(m["content"] == "interrupted response" for m in session_after.messages)
await loop.close_mcp() await loop.aclose()
class TestAutoCompactIntegration: class TestAutoCompactIntegration:
@@ -779,7 +784,7 @@ class TestAutoCompactIntegration:
# The new message should be processed (response exists) # The new message should be processed (response exists)
assert response is not None assert response is not None
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path): async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path):
@@ -807,7 +812,7 @@ class TestAutoCompactIntegration:
content = str(persisted.get("content", "")) content = str(persisted.get("content", ""))
assert "[Runtime Context" not in content assert "[Runtime Context" not in content
assert "[/Runtime Context]" not in content assert "[/Runtime Context]" not in content
await loop.close_mcp() await loop.aclose()
class TestProactiveAutoCompact: class TestProactiveAutoCompact:
@@ -870,7 +875,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 1 assert len(session_after.messages) == 1
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proactive_archive_on_idle_tick(self, tmp_path): async def test_proactive_archive_on_idle_tick(self, tmp_path):
@@ -897,7 +902,7 @@ class TestProactiveAutoCompact:
entry = loop.auto_compact._summaries.get("cli:test") entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None assert entry is not None
assert entry[0] == "User chatted about old things." assert entry[0] == "User chatted about old things."
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proactive_archive_skips_dream_sessions(self, tmp_path): async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
@@ -918,7 +923,7 @@ class TestProactiveAutoCompact:
assert _fake_compact.state["count"] == 0 assert _fake_compact.state["count"] == 0
assert "dream:20260602-155256" not in loop.auto_compact._archiving assert "dream:20260602-155256" not in loop.auto_compact._archiving
assert "dream:20260602-155256" not in loop.auto_compact._summaries assert "dream:20260602-155256" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_proactive_archive_when_active(self, tmp_path): async def test_no_proactive_archive_when_active(self, tmp_path):
@@ -932,7 +937,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 1 assert len(session_after.messages) == 1
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_duplicate_archive(self, tmp_path): async def test_no_duplicate_archive(self, tmp_path):
@@ -968,7 +973,7 @@ class TestProactiveAutoCompact:
# Clean up # Clean up
block_forever.set() block_forever.set()
await _drain_background_tasks(loop) await _drain_background_tasks(loop)
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proactive_archive_error_does_not_block(self, tmp_path): async def test_proactive_archive_error_does_not_block(self, tmp_path):
@@ -989,7 +994,7 @@ class TestProactiveAutoCompact:
# Key should be removed from _archiving (finally block) # Key should be removed from _archiving (finally block)
assert "cli:test" not in loop.auto_compact._archiving assert "cli:test" not in loop.auto_compact._archiving
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proactive_archive_skips_empty_sessions(self, tmp_path): async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
@@ -1005,7 +1010,7 @@ class TestProactiveAutoCompact:
# Empty session should not produce a summary # Empty session should not produce a summary
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skip_expired_session_with_active_agent_task(self, tmp_path): async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
@@ -1026,7 +1031,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 # All messages preserved assert len(session_after.messages) == 12 # All messages preserved
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_archive_after_active_task_completes(self, tmp_path): async def test_archive_after_active_task_completes(self, tmp_path):
@@ -1047,7 +1052,7 @@ class TestProactiveAutoCompact:
# Second tick: task completed, should archive # Second tick: task completed, should archive
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 assert _fake_compact.state["count"] == 1
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path): async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
@@ -1083,7 +1088,7 @@ class TestProactiveAutoCompact:
assert len(s2_after.messages) == 12 # Preserved assert len(s2_after.messages) == 12 # Preserved
s3_after = loop.sessions.get_or_create("cli:recent") s3_after = loop.sessions.get_or_create("cli:recent")
assert len(s3_after.messages) == 1 # Preserved assert len(s3_after.messages) == 1 # Preserved
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_reschedule_after_successful_archive(self, tmp_path): async def test_no_reschedule_after_successful_archive(self, tmp_path):
@@ -1104,7 +1109,7 @@ class TestProactiveAutoCompact:
# Second tick: should NOT re-schedule because the session has no removable tail. # Second tick: should NOT re-schedule because the session has no removable tail.
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path): async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
@@ -1124,7 +1129,7 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0 assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_can_be_compacted_again_after_new_messages(self, tmp_path): async def test_session_can_be_compacted_again_after_new_messages(self, tmp_path):
@@ -1155,7 +1160,7 @@ class TestProactiveAutoCompact:
# Second compact cycle should succeed # Second compact cycle should succeed
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime()) await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert _fake_compact.state["count"] == 2 assert _fake_compact.state["count"] == 2
await loop.close_mcp() await loop.aclose()
class TestSummaryPersistence: class TestSummaryPersistence:
@@ -1182,7 +1187,7 @@ class TestSummaryPersistence:
assert meta is not None assert meta is not None
assert meta["text"] == "User said hello." assert meta["text"] == "User said hello."
assert "last_active" in meta assert "last_active" in meta
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summary_recovered_after_restart(self, tmp_path): async def test_summary_recovered_after_restart(self, tmp_path):
@@ -1218,7 +1223,7 @@ class TestSummaryPersistence:
assert "Previous conversation summary" in summary assert "Previous conversation summary" in summary
# _last_summary persists in metadata for restart survival. # _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_metadata_persists_for_restart(self, tmp_path): async def test_metadata_persists_for_restart(self, tmp_path):
@@ -1246,7 +1251,7 @@ class TestSummaryPersistence:
assert "Summary." in summary2 assert "Summary." in summary2
# _last_summary persists in metadata for restart survival. # _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_metadata_cleanup_on_inmemory_path(self, tmp_path): async def test_metadata_cleanup_on_inmemory_path(self, tmp_path):
@@ -1272,7 +1277,7 @@ class TestSummaryPersistence:
assert summary is not None assert summary is not None
# _last_summary persists in metadata for restart survival. # _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_new_summary_overrides_old(self, tmp_path): async def test_new_summary_overrides_old(self, tmp_path):
@@ -1314,7 +1319,7 @@ class TestSummaryPersistence:
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary2 is not None assert summary2 is not None
assert "Second summary." in summary2 assert "Second summary." in summary2
await loop.close_mcp() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_new_command_clears_last_summary(self, tmp_path): async def test_new_command_clears_last_summary(self, tmp_path):
@@ -1342,4 +1347,4 @@ class TestSummaryPersistence:
# After /new, metadata should no longer contain _last_summary # After /new, metadata should no longer contain _last_summary
fresh = loop.sessions.get_or_create("cli:test") fresh = loop.sessions.get_or_create("cli:test")
assert "_last_summary" not in fresh.metadata assert "_last_summary" not in fresh.metadata
await loop.close_mcp() await loop.aclose()
+5 -5
View File
@@ -538,7 +538,7 @@ class TestNewCommandArchival:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0 assert len(session_after.messages) == 0
await loop.close_mcp() await loop.aclose()
assert call_count == 1 assert call_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -572,7 +572,7 @@ class TestNewCommandArchival:
assert response is not None assert response is not None
assert "new session started" in response.content.lower() assert "new session started" in response.content.lower()
await loop.close_mcp() await loop.aclose()
assert archived_count == 3 assert archived_count == 3
assert archived_session_key == "cli:test" assert archived_session_key == "cli:test"
@@ -603,8 +603,8 @@ class TestNewCommandArchival:
assert loop.sessions.get_or_create("cli:test").messages == [] assert loop.sessions.get_or_create("cli:test").messages == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_mcp_drains_background_tasks(self, tmp_path: Path) -> None: async def test_aclose_drains_background_tasks(self, tmp_path: Path) -> None:
"""close_mcp waits for background tasks to complete.""" """aclose waits for background tasks to complete."""
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path) loop = self._make_loop(tmp_path)
@@ -632,5 +632,5 @@ class TestNewCommandArchival:
assert not archived.is_set() assert not archived.is_set()
release_archive.set() release_archive.set()
await loop.close_mcp() await loop.aclose()
assert archived.is_set() assert archived.is_set()
@@ -10,6 +10,7 @@ from unittest.mock import patch
from nanobot.providers.base import ToolCallRequest from nanobot.providers.base import ToolCallRequest
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderSpec
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}} GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
@@ -243,3 +244,251 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None:
sanitized = provider._sanitize_messages(messages) sanitized = provider._sanitize_messages(messages)
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
# ── Replay to Gemini: preserve or backfill thought signatures ─────────
def _gemini_provider() -> OpenAICompatProvider:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
return OpenAICompatProvider(
spec=ProviderSpec(
name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY"
)
)
def _tool_call(tc_id: str, name: str, *, signed: bool = False) -> dict:
tc: dict = {
"id": tc_id,
"type": "function",
"function": {"name": name, "arguments": "{}"},
}
if signed:
tc["extra_content"] = GEMINI_EXTRA
return tc
def test_gemini_backfills_unsigned_tool_calls_and_keeps_results() -> None:
"""Cross-provider history stays intact and receives the documented fallback."""
provider = _gemini_provider()
messages = [
{"role": "user", "content": "check the sensor"},
{
"role": "assistant",
"content": "On it.",
"tool_calls": [_tool_call("default_api:exec", "exec")],
},
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
{"role": "user", "content": "thanks"},
]
sanitized = provider._sanitize_messages(messages)
assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"]
call = sanitized[1]["tool_calls"][0]
assert call["extra_content"]["google"]["thought_signature"] == (
"skip_thought_signature_validator"
)
assert sanitized[2]["tool_call_id"] == call["id"]
assert sanitized[2]["content"] == "done"
def test_gemini_preserves_parallel_calls_when_only_first_is_signed() -> None:
"""Gemini signs only the first native parallel call; all calls must replay."""
provider = _gemini_provider()
messages = [
{"role": "user", "content": "do both"},
{
"role": "assistant",
"content": None,
"tool_calls": [
_tool_call("call_signed", "read_file", signed=True),
_tool_call("default_api:exec", "exec"),
],
},
{"role": "tool", "content": "file contents", "tool_call_id": "call_signed"},
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
{"role": "user", "content": "thanks"},
]
sanitized = provider._sanitize_messages(messages)
assert [m["role"] for m in sanitized] == [
"user",
"assistant",
"tool",
"tool",
"user",
]
calls = sanitized[1]["tool_calls"]
assert len(calls) == 2
assert calls[0]["extra_content"] == GEMINI_EXTRA
assert sanitized[2]["tool_call_id"] == calls[0]["id"]
assert sanitized[2]["content"] == "file contents"
assert "extra_content" not in calls[1]
assert sanitized[3]["tool_call_id"] == calls[1]["id"]
assert sanitized[3]["content"] == "done"
def test_gemini_backfills_only_first_cross_provider_parallel_call() -> None:
provider = _gemini_provider()
messages = [
{"role": "user", "content": "do both"},
{
"role": "assistant",
"content": None,
"tool_calls": [
_tool_call("call_1", "read_file"),
_tool_call("call_2", "exec"),
],
},
{"role": "tool", "content": "file contents", "tool_call_id": "call_1"},
{"role": "tool", "content": "done", "tool_call_id": "call_2"},
]
sanitized = provider._sanitize_messages(messages)
calls = sanitized[1]["tool_calls"]
assert len(calls) == 2
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
"skip_thought_signature_validator"
)
assert "extra_content" not in calls[1]
assert [message["content"] for message in sanitized[2:]] == ["file contents", "done"]
def test_gemini_requires_signature_on_first_parallel_call() -> None:
provider = _gemini_provider()
messages = [
{"role": "user", "content": "do both"},
{
"role": "assistant",
"content": None,
"tool_calls": [
_tool_call("call_1", "read_file"),
_tool_call("call_2", "exec", signed=True),
],
},
{"role": "tool", "content": "contents", "tool_call_id": "call_1"},
{"role": "tool", "content": "done", "tool_call_id": "call_2"},
]
sanitized = provider._sanitize_messages(messages)
calls = sanitized[1]["tool_calls"]
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
"skip_thought_signature_validator"
)
assert calls[1]["extra_content"] == GEMINI_EXTRA
def test_gemini_replay_preserves_signed_tool_calls() -> None:
"""A pure Gemini-origin history replays unchanged (signature intact)."""
provider = _gemini_provider()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [_tool_call("call_1", "get_weather", signed=True)],
},
{"role": "tool", "content": "sunny", "tool_call_id": "call_1"},
{"role": "user", "content": "thanks"},
]
sanitized = provider._sanitize_messages(messages)
assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"]
calls = sanitized[1]["tool_calls"]
assert len(calls) == 1
assert calls[0]["extra_content"] == GEMINI_EXTRA
assert sanitized[2]["tool_call_id"] == calls[0]["id"]
def test_non_gemini_provider_keeps_unsigned_tool_calls() -> None:
"""The filter is Gemini-scoped: other providers still replay unsigned calls."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [_tool_call("default_api:exec", "exec")],
},
{"role": "tool", "content": "done", "tool_call_id": "default_api:exec"},
{"role": "user", "content": "thanks"},
]
sanitized = provider._sanitize_messages(messages)
assert len(sanitized[1]["tool_calls"]) == 1
assert sanitized[2]["role"] == "tool"
assert sanitized[2]["tool_call_id"] == sanitized[1]["tool_calls"][0]["id"]
def test_gemini_drops_malformed_tool_call_entries_without_crashing() -> None:
provider = _gemini_provider()
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None, "tool_calls": [None]},
{"role": "user", "content": "continue"},
]
sanitized = provider._sanitize_messages(messages)
assert not any(message.get("tool_calls") for message in sanitized)
def test_gemini_matches_duplicate_tool_ids_by_call_instance() -> None:
provider = _gemini_provider()
messages = [
{"role": "user", "content": "old request"},
{
"role": "assistant",
"content": None,
"tool_calls": [_tool_call("reused", "old_tool")],
},
{"role": "tool", "content": "old result", "tool_call_id": "reused"},
{"role": "user", "content": "new request"},
{
"role": "assistant",
"content": None,
"tool_calls": [_tool_call("reused", "new_tool", signed=True)],
},
{"role": "tool", "content": "new result", "tool_call_id": "reused"},
]
sanitized = provider._sanitize_messages(messages)
assert any(message.get("content") == "old result" for message in sanitized)
assert any(message.get("content") == "new result" for message in sanitized)
calls = [
call
for message in sanitized
for call in message.get("tool_calls", [])
]
assert len(calls) == 2
assert calls[0]["function"]["name"] == "old_tool"
assert calls[0]["extra_content"]["google"]["thought_signature"] == (
"skip_thought_signature_validator"
)
assert calls[1]["function"]["name"] == "new_tool"
def test_gemini_backfill_does_not_mutate_caller_history() -> None:
provider = _gemini_provider()
call = _tool_call("call_1", "read_file")
messages = [
{"role": "user", "content": "read it"},
{"role": "assistant", "content": None, "tool_calls": [call]},
{"role": "tool", "content": "contents", "tool_call_id": "call_1"},
]
sanitized = provider._sanitize_messages(messages)
assert "extra_content" not in call
assert sanitized[1]["tool_calls"][0]["extra_content"]["google"][
"thought_signature"
] == "skip_thought_signature_validator"
@@ -93,7 +93,6 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_direct_reuses_existing_session_lock(tmp_path) -> None: async def test_process_direct_reuses_existing_session_lock(tmp_path) -> None:
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
loop._connect_mcp = AsyncMock()
session_key = "api:fixed" session_key = "api:fixed"
lock = loop._session_locks.setdefault(session_key, asyncio.Lock()) lock = loop._session_locks.setdefault(session_key, asyncio.Lock())
await lock.acquire() await lock.acquire()
-3
View File
@@ -1519,13 +1519,11 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_direct_rejects_reserved_system_channel(tmp_path: Path) -> None: async def test_process_direct_rejects_reserved_system_channel(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
loop._process_message = AsyncMock(return_value=None) # type: ignore[method-assign] loop._process_message = AsyncMock(return_value=None) # type: ignore[method-assign]
with pytest.raises(ValueError, match="reserved for internal messages"): with pytest.raises(ValueError, match="reserved for internal messages"):
await loop.process_direct("external input", channel="system") await loop.process_direct("external input", channel="system")
loop._connect_mcp.assert_not_awaited()
loop._process_message.assert_not_awaited() loop._process_message.assert_not_awaited()
@@ -1534,7 +1532,6 @@ async def test_process_direct_skip_user_persist_does_not_save_retry_user(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
loop._connect_mcp = AsyncMock()
session = loop.sessions.get_or_create("api:default") session = loop.sessions.get_or_create("api:default")
session.add_message("user", "hello") session.add_message("user", "hello")
session.add_message("assistant", "previous empty-response attempt") session.add_message("assistant", "previous empty-response attempt")
+1 -2
View File
@@ -123,8 +123,7 @@ async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch
await asyncio.sleep(0) await asyncio.sleep(0)
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider) loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock()) monkeypatch.setattr(loop, "aclose", AsyncMock())
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
terminate_exec_sessions = AsyncMock(return_value=1) terminate_exec_sessions = AsyncMock(return_value=1)
monkeypatch.setattr( monkeypatch.setattr(
loop._exec_session_manager, loop._exec_session_manager,
+48
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import inspect
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -11,8 +12,10 @@ from nanobot.agent.tools.context import (
current_request_context, current_request_context,
reset_request_context, reset_request_context,
) )
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Config
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META
@@ -56,6 +59,51 @@ class _Tools:
return (self.tool, arguments, None) if name == "cron" else (None, arguments, None) return (self.tool, arguments, None) if name == "cron" else (None, arguments, None)
def test_loop_registers_default_tools_in_injected_registry(tmp_path: Path) -> None:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
registry = ToolRegistry()
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
tool_registry=registry,
)
assert loop.tools is registry
assert registry.has("read_file")
def _config_for_loop(tmp_path: Path) -> Config:
return Config.model_validate({"agents": {"defaults": {"workspace": str(tmp_path)}}})
def _provider_for_loop() -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
return provider
def test_loop_from_config_requires_caller_owned_registry(tmp_path: Path) -> None:
signature = inspect.signature(AgentLoop.from_config)
with pytest.raises(TypeError, match="tool_registry"):
signature.bind(_config_for_loop(tmp_path))
def test_loop_from_config_uses_caller_owned_registry(tmp_path: Path) -> None:
registry = ToolRegistry()
loop = AgentLoop.from_config(
_config_for_loop(tmp_path),
tool_registry=registry,
provider=_provider_for_loop(),
)
assert loop.tools is registry
assert loop.tools.has("read_file")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None: async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None:
provider = MagicMock() provider = MagicMock()
+165 -97
View File
@@ -1,4 +1,4 @@
"""Tests for MCP connection lifecycle in AgentLoop.""" """Tests for the application-owned MCP provider lifecycle."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,7 @@ import asyncio
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock
import anyio import anyio
import pytest import pytest
@@ -15,11 +15,10 @@ from mcp.shared.exceptions import McpError
from mcp.shared.message import SessionMessage from mcp.shared.message import SessionMessage
from mcp.types import ErrorData from mcp.types import ErrorData
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools import mcp as mcp_runtime from nanobot.agent.tools import mcp as mcp_runtime
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper from nanobot.agent.tools.mcp import MCPProvider, MCPResourceWrapper, MCPToolWrapper
from nanobot.bus.queue import MessageBus from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import MCPServerConfig
@@ -74,18 +73,20 @@ class _FakeMcpTool(Tool):
return "ok" return "ok"
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop: def _stdio_server(command: str = "test-mcp") -> MCPServerConfig:
bus = MessageBus() return MCPServerConfig(type="stdio", command=command)
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096 def _make_provider(
return AgentLoop( *,
bus=bus, mcp_servers: dict[str, MCPServerConfig] | None = None,
provider=provider, ) -> tuple[MCPProvider, ToolRegistry]:
workspace=tmp_path, registry = ToolRegistry()
model="test-model", provider = MCPProvider(
mcp_servers=mcp_servers or {"test": object()}, mcp_servers if mcp_servers is not None else {"test": _stdio_server()},
registry,
) )
return provider, registry
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -140,7 +141,7 @@ async def test_owned_mcp_connection_closes_from_its_owner_task():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch): async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch):
loop = _make_loop(tmp_path) provider, _registry = _make_provider()
attempts = 0 attempts = 0
async def _fake_connect(_servers, _registry): async def _fake_connect(_servers, _registry):
@@ -150,12 +151,12 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
await loop._connect_mcp() await provider.connect()
await loop._connect_mcp() await provider.connect()
assert attempts == 2 assert attempts == 2
assert loop._mcp_stacks == {} assert provider.connected_server_names == set()
assert loop.mcp_runtime_status() == {"test": "failed"} assert provider.runtime_status() == {"test": "failed"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -168,7 +169,7 @@ async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
auth="oauth", auth="oauth",
url="https://mcp.example.com/mcp", url="https://mcp.example.com/mcp",
) )
loop = _make_loop(tmp_path, mcp_servers={"oauth-app": cfg}) provider, _registry = _make_provider(mcp_servers={"oauth-app": cfg})
connect = AsyncMock() connect = AsyncMock()
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect)
monkeypatch.setattr( monkeypatch.setattr(
@@ -176,19 +177,20 @@ async def test_connect_mcp_does_not_report_failure_before_oauth_authorization(
lambda _name, _url: False, lambda _name, _url: False,
) )
await loop._connect_mcp() await provider.connect()
connect.assert_not_awaited() connect.assert_not_awaited()
assert loop.mcp_runtime_status() == {} assert provider.runtime_status() == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agent_loop_run_closes_mcp_from_connection_owner_task( async def test_mcp_provider_closes_connections_independently_from_agent_loop(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
loop = _make_loop(tmp_path, mcp_servers={"playwright": object()}) provider, registry = _make_provider(
connected = asyncio.Event() mcp_servers={"playwright": _stdio_server("playwright")}
)
owner_tasks: list[asyncio.Task | None] = [] owner_tasks: list[asyncio.Task | None] = []
closed_tasks: list[asyncio.Task | None] = [] closed_tasks: list[asyncio.Task | None] = []
@@ -203,40 +205,38 @@ async def test_agent_loop_run_closes_mcp_from_connection_owner_task(
async def _fake_connect(servers, _registry): async def _fake_connect(servers, _registry):
stacks = {name: _OwnerCheckedStack() for name in servers} stacks = {name: _OwnerCheckedStack() for name in servers}
connected.set()
return stacks return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
task = asyncio.create_task(loop.run()) await provider.connect()
await asyncio.wait_for(connected.wait(), timeout=1) registry.register(_FakeMcpTool("mcp_playwright_search"))
loop.stop() await provider.aclose()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert owner_tasks assert owner_tasks
assert closed_tasks == owner_tasks assert closed_tasks == owner_tasks
assert loop._mcp_stacks == {} assert provider.connected_server_names == set()
assert registry.get("mcp_playwright_search") is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_server_ignores_server_cancelled_error(tmp_path): async def test_close_server_ignores_server_cancelled_error(tmp_path):
loop = _make_loop(tmp_path) provider, _registry = _make_provider()
class _ServerCancelledStack: class _ServerCancelledStack:
async def aclose(self) -> None: async def aclose(self) -> None:
raise asyncio.CancelledError() raise asyncio.CancelledError()
loop._mcp_stacks = {"test": _ServerCancelledStack()} provider._connections = {"test": _ServerCancelledStack()}
await mcp_runtime._close_server(loop, "test") await provider._close_server("test")
assert loop._mcp_stacks == {} assert provider.connected_server_names == set()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_mcp_servers_continues_after_server_cancelled_error(tmp_path): async def test_provider_close_continues_after_server_cancelled_error(tmp_path):
loop = _make_loop(tmp_path) provider, _registry = _make_provider()
closed: list[str] = [] closed: list[str] = []
class _ServerCancelledStack: class _ServerCancelledStack:
@@ -247,21 +247,53 @@ async def test_close_mcp_servers_continues_after_server_cancelled_error(tmp_path
async def aclose(self) -> None: async def aclose(self) -> None:
closed.append("second") closed.append("second")
loop._mcp_stacks = { provider._connections = {
"first": _ServerCancelledStack(), "first": _ServerCancelledStack(),
"second": _TrackedStack(), "second": _TrackedStack(),
} }
await mcp_runtime.close_mcp_servers(loop) await provider.aclose()
assert closed == ["second"] assert closed == ["second"]
assert loop._mcp_stacks == {} assert provider.connected_server_names == set()
@pytest.mark.asyncio
async def test_provider_close_finishes_other_connections_before_propagating_cancellation(
tmp_path,
):
provider, _registry = _make_provider()
started = asyncio.Event()
closed: list[str] = []
class _BlockingStack:
async def aclose(self) -> None:
started.set()
await asyncio.Event().wait()
class _TrackedStack:
async def aclose(self) -> None:
closed.append("second")
provider._connections = {
"first": _BlockingStack(),
"second": _TrackedStack(),
}
task = asyncio.create_task(provider.aclose())
await asyncio.wait_for(started.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed == ["second"]
assert provider.connected_server_names == set()
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("close_all", [False, True], ids=["single", "all"]) @pytest.mark.parametrize("close_all", [False, True], ids=["single", "all"])
async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all: bool): async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all: bool):
loop = _make_loop(tmp_path) provider, _registry = _make_provider()
started = asyncio.Event() started = asyncio.Event()
class _BlockingStack: class _BlockingStack:
@@ -269,12 +301,12 @@ async def test_mcp_cleanup_re_raises_external_cancellation(tmp_path, close_all:
started.set() started.set()
await asyncio.Event().wait() await asyncio.Event().wait()
loop._mcp_stacks = {"test": _BlockingStack()} provider._connections = {"test": _BlockingStack()}
if close_all: if close_all:
task = asyncio.create_task(mcp_runtime.close_mcp_servers(loop)) task = asyncio.create_task(provider.aclose())
else: else:
task = asyncio.create_task(mcp_runtime._close_server(loop, "test")) task = asyncio.create_task(provider._close_server("test"))
await asyncio.wait_for(started.wait(), timeout=1) await asyncio.wait_for(started.wait(), timeout=1)
task.cancel() task.cancel()
@@ -312,41 +344,38 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
return stacks return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={}) provider, registry = _make_provider(mcp_servers={})
added = await mcp_runtime.reload_servers(loop, loop.tools) added = await provider.reload()
assert added["ok"] is True assert added["ok"] is True
assert added["added"] == ["browserbase"] assert added["added"] == ["browserbase"]
assert loop.tools.has("mcp_browserbase_navigate") assert registry.has("mcp_browserbase_navigate")
assert "browserbase" in loop._mcp_stacks assert provider.connected_server_names == {"browserbase"}
config = load_config() config = load_config()
del config.tools.mcp_servers["browserbase"] del config.tools.mcp_servers["browserbase"]
save_config(config) save_config(config)
removed = await mcp_runtime.reload_servers(loop, loop.tools) removed = await provider.reload()
assert removed["ok"] is True assert removed["ok"] is True
assert removed["removed"] == ["browserbase"] assert removed["removed"] == ["browserbase"]
assert not loop.tools.has("mcp_browserbase_navigate") assert not registry.has("mcp_browserbase_navigate")
assert "browserbase" not in loop._mcp_stacks assert provider.connected_server_names == set()
assert closed == ["browserbase"] assert closed == ["browserbase"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_mcp_reload_reaches_runtime_control_without_restart( async def test_reload_is_a_direct_provider_operation_without_an_agent_loop(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
config_path = tmp_path / "config.json" browserbase = MCPServerConfig(
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio", type="stdio",
command="browserbase-mcp", command="browserbase-mcp",
) )
save_config(config) configured: dict[str, MCPServerConfig] = {"browserbase": browserbase}
closed: list[str] = [] closed: list[str] = []
@@ -364,37 +393,68 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart(
return stacks return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={}) registry = ToolRegistry()
provider = MCPProvider({}, registry, server_loader=lambda: configured)
async def _handle_one_runtime_control() -> None: result = await provider.reload()
msg = await loop.bus.consume_inbound()
handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
assert handled is True
consumer = asyncio.create_task(_handle_one_runtime_control())
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
await consumer
assert result["ok"] is True assert result["ok"] is True
assert result["added"] == ["browserbase"] assert result["added"] == ["browserbase"]
assert result["requires_restart"] is False assert result["requires_restart"] is False
assert loop.tools.has("mcp_browserbase_navigate") assert registry.has("mcp_browserbase_navigate")
config = load_config() configured = {}
del config.tools.mcp_servers["browserbase"]
save_config(config)
consumer = asyncio.create_task(_handle_one_runtime_control()) result = await provider.reload()
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
await consumer
assert result["ok"] is True assert result["ok"] is True
assert result["removed"] == ["browserbase"] assert result["removed"] == ["browserbase"]
assert result["requires_restart"] is False assert result["requires_restart"] is False
assert not loop.tools.has("mcp_browserbase_navigate") assert not registry.has("mcp_browserbase_navigate")
assert closed == ["browserbase"] assert closed == ["browserbase"]
@pytest.mark.asyncio
async def test_reload_timeout_marks_attempted_server_failed_and_allows_retry(
monkeypatch: pytest.MonkeyPatch,
):
server = _stdio_server("slow-mcp")
started = asyncio.Event()
attempts = 0
async def _fake_connect(servers, _registry):
nonlocal attempts
attempts += 1
if attempts == 1:
started.set()
await asyncio.Event().wait()
stack = AsyncExitStack()
await stack.__aenter__()
return {name: stack for name in servers}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
provider = MCPProvider(
{"test": server},
ToolRegistry(),
server_loader=lambda: {"test": server},
)
reload_task = asyncio.create_task(provider.reload())
await asyncio.wait_for(started.wait(), timeout=1.0)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(reload_task, timeout=0.01)
assert provider.connected_server_names == set()
assert provider.runtime_status() == {"test": "failed"}
result = await provider.reload()
assert result["ok"] is True
assert provider.connected_server_names == {"test"}
assert provider.runtime_status() == {"test": "connected"}
await provider.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reload_mcp_servers_retries_configured_server_without_live_stack( async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
tmp_path, tmp_path,
@@ -419,16 +479,18 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
return stacks return stacks
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]}) provider, registry = _make_provider(
mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]}
)
result = await mcp_runtime.reload_servers(loop, loop.tools) result = await provider.reload()
assert result["ok"] is True assert result["ok"] is True
assert result["added"] == [] assert result["added"] == []
assert result["changed"] == [] assert result["changed"] == []
assert result["retried"] == ["browserbase"] assert result["retried"] == ["browserbase"]
assert loop.tools.has("mcp_browserbase_navigate") assert registry.has("mcp_browserbase_navigate")
await loop.close_mcp() await provider.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -465,16 +527,16 @@ async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials", "nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
lambda name, _url: name == "linear", lambda name, _url: name == "linear",
) )
loop = _make_loop(tmp_path, mcp_servers={"notion": notion}) provider, _registry = _make_provider(mcp_servers={"notion": notion})
result = await mcp_runtime.reload_servers(loop, loop.tools) result = await provider.reload()
assert attempted == ["linear"] assert attempted == ["linear"]
assert result["ok"] is True assert result["ok"] is True
assert result["failed"] == [] assert result["failed"] == []
assert result["retried"] == [] assert result["retried"] == []
assert result["connected"] == ["linear"] assert result["connected"] == ["linear"]
await loop.close_mcp() await provider.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -482,7 +544,9 @@ async def test_mcp_tool_reconnects_after_session_terminated(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
loop = _make_loop(tmp_path, mcp_servers={"remote": object()}) provider, registry = _make_provider(
mcp_servers={"remote": _stdio_server("remote")}
)
closed: list[str] = [] closed: list[str] = []
sessions: list[Any] = [] sessions: list[Any] = []
connect_count = 0 connect_count = 0
@@ -525,8 +589,8 @@ async def test_mcp_tool_reconnects_after_session_terminated(
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
await loop._connect_mcp() await provider.connect()
old_tool = loop.tools.get("mcp_remote_quote") old_tool = registry.get("mcp_remote_quote")
assert isinstance(old_tool, MCPToolWrapper) assert isinstance(old_tool, MCPToolWrapper)
output = await old_tool.execute(symbol="AAPL") output = await old_tool.execute(symbol="AAPL")
@@ -536,8 +600,8 @@ async def test_mcp_tool_reconnects_after_session_terminated(
assert closed == ["remote"] assert closed == ["remote"]
assert sessions[0].call_count == 1 assert sessions[0].call_count == 1
assert sessions[1].call_count == 1 assert sessions[1].call_count == 1
assert "remote" in loop._mcp_stacks assert provider.connected_server_names == {"remote"}
assert loop.tools.get("mcp_remote_quote") is not old_tool assert registry.get("mcp_remote_quote") is not old_tool
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -545,7 +609,9 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
loop = _make_loop(tmp_path, mcp_servers={"remote_": object()}) provider, registry = _make_provider(
mcp_servers={"remote_": _stdio_server("remote")}
)
connect_count = 0 connect_count = 0
class _FakeSession: class _FakeSession:
@@ -578,15 +644,15 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
await loop._connect_mcp() await provider.connect()
old_tool = loop.tools.get("mcp_remote_quote") old_tool = registry.get("mcp_remote_quote")
assert isinstance(old_tool, MCPToolWrapper) assert isinstance(old_tool, MCPToolWrapper)
output = await old_tool.execute() output = await old_tool.execute()
assert output == "recovered" assert output == "recovered"
assert connect_count == 2 assert connect_count == 2
assert loop.tools.get("mcp_remote_quote") is not old_tool assert registry.get("mcp_remote_quote") is not old_tool
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -594,7 +660,9 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
loop = _make_loop(tmp_path, mcp_servers={"remote": object()}) provider, registry = _make_provider(
mcp_servers={"remote": _stdio_server("remote")}
)
closed: list[str] = [] closed: list[str] = []
connect_count = 0 connect_count = 0
@@ -638,9 +706,9 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
await loop._connect_mcp() await provider.connect()
old_alpha = loop.tools.get("mcp_remote_resource_alpha") old_alpha = registry.get("mcp_remote_resource_alpha")
old_beta = loop.tools.get("mcp_remote_resource_beta") old_beta = registry.get("mcp_remote_resource_beta")
assert isinstance(old_alpha, MCPResourceWrapper) assert isinstance(old_alpha, MCPResourceWrapper)
assert isinstance(old_beta, MCPResourceWrapper) assert isinstance(old_beta, MCPResourceWrapper)
+15 -26
View File
@@ -15,15 +15,13 @@ import asyncio
import multiprocessing import multiprocessing
import socket import socket
import time import time
from unittest.mock import MagicMock
import httpx import httpx
import pytest import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools import mcp as mcp_module from nanobot.agent.tools import mcp as mcp_module
from nanobot.agent.tools.mcp import MCPToolWrapper from nanobot.agent.tools.mcp import MCPProvider, MCPToolWrapper
from nanobot.bus.queue import MessageBus from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import MCPServerConfig
from nanobot.security import network as security_network from nanobot.security import network as security_network
@@ -113,18 +111,9 @@ def mcp_server_url():
process.join(timeout=2.0) process.join(timeout=2.0)
def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop: def _make_provider(*, mcp_servers: dict) -> tuple[MCPProvider, ToolRegistry]:
bus = MessageBus() registry = ToolRegistry()
provider = MagicMock() return MCPProvider(mcp_servers, registry), registry
provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
return AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
mcp_servers=mcp_servers,
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -170,12 +159,12 @@ async def test_mcp_reconnect_after_session_timeout(tmp_path, mcp_server_url):
tool_timeout=_TOOL_TIMEOUT_SECONDS, tool_timeout=_TOOL_TIMEOUT_SECONDS,
enabled_tools=["*"], enabled_tools=["*"],
) )
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg}) provider, registry = _make_provider(mcp_servers={"repro": cfg})
await asyncio.create_task(loop._connect_mcp()) await asyncio.create_task(provider.connect())
assert "repro" in loop._mcp_stacks assert provider.connected_server_names == {"repro"}
tool = loop.tools.get("mcp_repro_greet") tool = registry.get("mcp_repro_greet")
assert isinstance(tool, MCPToolWrapper) assert isinstance(tool, MCPToolWrapper)
output = await asyncio.create_task(tool.execute(name="first")) output = await asyncio.create_task(tool.execute(name="first"))
@@ -187,7 +176,7 @@ async def test_mcp_reconnect_after_session_timeout(tmp_path, mcp_server_url):
output = await asyncio.create_task(tool.execute(name="second")) output = await asyncio.create_task(tool.execute(name="second"))
assert "Hello, second" in output assert "Hello, second" in output
await asyncio.create_task(loop.close_mcp()) await asyncio.create_task(provider.aclose())
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -203,10 +192,10 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
tool_timeout=_TOOL_TIMEOUT_SECONDS, tool_timeout=_TOOL_TIMEOUT_SECONDS,
enabled_tools=["*"], enabled_tools=["*"],
) )
loop = _make_loop(tmp_path, mcp_servers={"repro": cfg}) provider, registry = _make_provider(mcp_servers={"repro": cfg})
await asyncio.create_task(loop._connect_mcp()) await asyncio.create_task(provider.connect())
tool = loop.tools.get("mcp_repro_greet") tool = registry.get("mcp_repro_greet")
assert isinstance(tool, MCPToolWrapper) assert isinstance(tool, MCPToolWrapper)
await asyncio.create_task(tool.execute(name="first")) await asyncio.create_task(tool.execute(name="first"))
@@ -224,7 +213,7 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
monkeypatch.setattr(mcp_module, "connect_mcp_servers", gated_connect) monkeypatch.setattr(mcp_module, "connect_mcp_servers", gated_connect)
call_task = asyncio.create_task(tool.execute(name="second")) call_task = asyncio.create_task(tool.execute(name="second"))
await asyncio.wait_for(reconnect_started.wait(), timeout=5) await asyncio.wait_for(reconnect_started.wait(), timeout=5)
close_task = asyncio.create_task(loop.close_mcp()) close_task = asyncio.create_task(provider.aclose())
await asyncio.sleep(0) await asyncio.sleep(0)
finish_reconnect.set() finish_reconnect.set()
@@ -245,4 +234,4 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
unhandled.append(exc) unhandled.append(exc)
assert not unhandled, f"Unhandled exception leaked during reconnect/shutdown: {unhandled[0]}" assert not unhandled, f"Unhandled exception leaked during reconnect/shutdown: {unhandled[0]}"
assert loop._mcp_stacks == {} assert provider.connected_server_names == set()
+6 -1
View File
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeModelChanged from nanobot.bus.runtime_events import RuntimeModelChanged
from nanobot.config.errors import ConfigLoadError from nanobot.config.errors import ConfigLoadError
@@ -312,7 +313,11 @@ def test_settings_context_window_refreshes_runtime_state(
def loader(*, preset_name: str | None = None) -> ProviderSnapshot: def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
return load_provider_snapshot(config_path, preset_name=preset_name) return load_provider_snapshot(config_path, preset_name=preset_name)
loop = AgentLoop.from_config(config, provider_snapshot_loader=loader) loop = AgentLoop.from_config(
config,
tool_registry=ToolRegistry(),
provider_snapshot_loader=loader,
)
payload = update_agent_settings({"context_window_tokens": ["262144"]}) payload = update_agent_settings({"context_window_tokens": ["262144"]})
loop.runtime_resolver.invalidate() loop.runtime_resolver.invalidate()
+3 -2
View File
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.runtime_control import AgentRuntimeControl from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -390,7 +391,7 @@ def test_from_config_injects_default_preset(tmp_path) -> None:
}) })
fake_provider = _provider("openai/gpt-4.1") fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider): with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
loop = AgentLoop.from_config(config) loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
assert loop.model == "openai/gpt-4.1" assert loop.model == "openai/gpt-4.1"
assert loop.model_preset is None assert loop.model_preset is None
assert "default" in loop.model_presets assert "default" in loop.model_presets
@@ -407,7 +408,7 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
}) })
fake_provider = _provider("openai/gpt-4.1") fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider): with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
loop = AgentLoop.from_config(config) loop = AgentLoop.from_config(config, tool_registry=ToolRegistry())
default_runtime = loop.runtime_resolver.runtime default_runtime = loop.runtime_resolver.runtime
resolved = loop.runtime_resolver.resolve_preset("fast") resolved = loop.runtime_resolver.resolve_preset("fast")
assert resolved.model == "openai/gpt-4.1-mini" assert resolved.model == "openai/gpt-4.1-mini"
+6 -9
View File
@@ -56,7 +56,7 @@ class TestHandleStop:
assert "No active task" in out.content assert "No active task" in out.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_mcp_cancels_active_turn_before_resources(self): async def test_aclose_cancels_active_turn_before_resources(self):
loop, _bus = _make_loop() loop, _bus = _make_loop()
events: list[str] = [] events: list[str] = []
@@ -76,14 +76,13 @@ class TestHandleStop:
loop.subagents.close = close_subagents loop.subagents.close = close_subagents
loop._exec_session_manager.close_all = AsyncMock() loop._exec_session_manager.close_all = AsyncMock()
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()): await loop.aclose()
await loop.close_mcp()
assert events == ["turn_cancelled", "resources_closed"] assert events == ["turn_cancelled", "resources_closed"]
assert task.cancelled() assert task.cancelled()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_mcp_serializes_duplicate_cleanup(self): async def test_aclose_serializes_duplicate_cleanup(self):
loop, _bus = _make_loop() loop, _bus = _make_loop()
entered = asyncio.Event() entered = asyncio.Event()
release = asyncio.Event() release = asyncio.Event()
@@ -100,10 +99,9 @@ class TestHandleStop:
loop.subagents.close = close_subagents loop.subagents.close = close_subagents
loop._exec_session_manager.close_all = AsyncMock() loop._exec_session_manager.close_all = AsyncMock()
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()): first = asyncio.create_task(loop.aclose())
first = asyncio.create_task(loop.close_mcp())
await entered.wait() await entered.wait()
second = asyncio.create_task(loop.close_mcp()) second = asyncio.create_task(loop.aclose())
await asyncio.sleep(0) await asyncio.sleep(0)
assert not second.done() assert not second.done()
release.set() release.set()
@@ -172,8 +170,7 @@ class TestDispatch:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch): async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
loop, bus = _make_loop() loop, bus = _make_loop()
loop._connect_mcp = AsyncMock() loop.aclose = AsyncMock()
loop.close_mcp = AsyncMock()
loop.auto_compact.check_expired = MagicMock() loop.auto_compact.check_expired = MagicMock()
warnings: list[str] = [] warnings: list[str] = []
calls = 0 calls = 0
-21
View File
@@ -493,20 +493,6 @@ class TestModifyOpen:
assert "Set workspace" in result assert "Set workspace" in result
assert tool._runtime_control.snapshot().workspace == "/new/path" assert tool._runtime_control.snapshot().workspace == "/new/path"
@pytest.mark.asyncio
async def test_modify_mcp_servers_blocked(self):
"""_mcp_servers contains API credentials — must be blocked."""
tool = _make_tool()
result = await tool.execute(action="set", key="_mcp_servers", value={"evil": "leaked"})
assert "protected" in result
@pytest.mark.asyncio
async def test_modify_mcp_stacks_blocked(self):
"""_mcp_stacks holds connection handles — must be blocked."""
tool = _make_tool()
result = await tool.execute(action="set", key="_mcp_stacks", value={})
assert "protected" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_modify_pending_queues_blocked(self): async def test_modify_pending_queues_blocked(self):
"""_pending_queues controls message routing — must be blocked.""" """_pending_queues controls message routing — must be blocked."""
@@ -535,13 +521,6 @@ class TestModifyOpen:
result = await tool.execute(action="set", key="_background_tasks", value=[]) result = await tool.execute(action="set", key="_background_tasks", value=[])
assert "protected" in result assert "protected" in result
@pytest.mark.asyncio
async def test_inspect_mcp_servers_blocked(self):
"""_mcp_servers contains credentials — check must be blocked too."""
tool = _make_tool()
result = await tool.execute(action="check", key="_mcp_servers")
assert "not accessible" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_modify_wrapped_denied(self): async def test_modify_wrapped_denied(self):
"""__wrapped__ allows decorator bypass — must be denied.""" """__wrapped__ allows decorator bypass — must be denied."""
+21
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import subprocess
from nanobot.apps.cli.service import CliAppManager from nanobot.apps.cli.service import CliAppManager
@@ -67,3 +69,22 @@ def test_run_passes_filtered_env(monkeypatch, tmp_path) -> None:
env = captured.get("env") env = captured.get("env")
assert isinstance(env, dict) assert isinstance(env, dict)
assert "OPENAI_API_KEY" not in env assert "OPENAI_API_KEY" not in env
def test_management_subprocesses_use_filtered_env(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-leak")
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured.update(kwargs)
return subprocess.CompletedProcess(args[0], 0, stdout="ok", stderr="")
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
manager = CliAppManager(workspace=tmp_path, data_dir=tmp_path / "cli-apps")
manager._run_argv(["example-cli", "--help"], timeout=5)
env = captured.get("env")
assert isinstance(env, dict)
assert "OPENAI_API_KEY" not in env
assert env["PYTHONUNBUFFERED"] == "1"
+4 -4
View File
@@ -64,7 +64,7 @@ def test_interactive_agent_routes_a_complete_user_turn(
def __init__(self, bus) -> None: def __init__(self, bus) -> None:
self.bus = bus self.bus = bus
self.stopped = asyncio.Event() self.stopped = asyncio.Event()
self.close_mcp_calls = 0 self.aclose_calls = 0
async def run(self) -> None: async def run(self) -> None:
message = await self.bus.consume_inbound() message = await self.bus.consume_inbound()
@@ -97,8 +97,8 @@ def test_interactive_agent_routes_a_complete_user_turn(
def stop(self) -> None: def stop(self) -> None:
self.stopped.set() self.stopped.set()
async def close_mcp(self) -> None: async def aclose(self) -> None:
self.close_mcp_calls += 1 self.aclose_calls += 1
read_input = AsyncMock(side_effect=["hello nanobot", "exit"]) read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
print_response = MagicMock() print_response = MagicMock()
@@ -136,7 +136,7 @@ def test_interactive_agent_routes_a_complete_user_turn(
assert inbound.metadata == {"_wants_stream": True} assert inbound.metadata == {"_wants_stream": True}
loop = seen["loop"] loop = seen["loop"]
assert isinstance(loop, _AgentLoop) assert isinstance(loop, _AgentLoop)
assert loop.close_mcp_calls == 1 assert loop.aclose_calls == 1
assert len(renderers) == 1 assert len(renderers) == 1
renderer = renderers[0] renderer = renderers[0]
assert isinstance(renderer, _Renderer) assert isinstance(renderer, _Renderer)
+50 -19
View File
@@ -1543,7 +1543,7 @@ def mock_agent_runtime(tmp_path):
agent_loop.process_direct = AsyncMock( agent_loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"), return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
) )
agent_loop.close_mcp = AsyncMock(return_value=None) agent_loop.aclose = AsyncMock(return_value=None)
mock_from_config.return_value = agent_loop mock_from_config.return_value = agent_loop
yield { yield {
@@ -1621,7 +1621,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(channel="cli", chat_id="direct", content="ok") return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
@@ -1662,7 +1662,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(channel="cli", chat_id="direct", content="ok") return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
@@ -1712,7 +1712,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(channel="cli", chat_id="direct", content="ok") return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
@@ -1768,7 +1768,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(channel="cli", chat_id="direct", content="ok") return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
@@ -2062,7 +2062,7 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
return SimpleNamespace(content="") return SimpleNamespace(content="")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
async def run(self) -> None: async def run(self) -> None:
@@ -2738,10 +2738,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
seen["workspace"] = kwargs["workspace"] seen["workspace"] = kwargs["workspace"]
async def _connect_mcp(self) -> None: async def aclose(self) -> None:
return None
async def close_mcp(self) -> None:
return None return None
def _fake_create_app( def _fake_create_app(
@@ -2749,11 +2746,13 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
model_name: str, model_name: str,
request_timeout: float, request_timeout: float,
api_key: str = "", api_key: str = "",
prepare_agent=None,
): ):
seen["agent_loop"] = agent_loop seen["agent_loop"] = agent_loop
seen["model_name"] = model_name seen["model_name"] = model_name
seen["request_timeout"] = request_timeout seen["request_timeout"] = request_timeout
seen["api_key"] = api_key seen["api_key"] = api_key
seen["prepare_agent"] = prepare_agent
return _FakeApiApp() return _FakeApiApp()
def _fake_run_app(api_app, host: str, port: int, print): def _fake_run_app(api_app, host: str, port: int, print):
@@ -2914,7 +2913,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
async def submit_cron_turn(self, _msg: InboundMessage): async def submit_cron_turn(self, _msg: InboundMessage):
raise AssertionError("unbound cron job must not run as a bound cron turn") raise AssertionError("unbound cron job must not run as a bound cron turn")
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
async def run(self) -> None: async def run(self) -> None:
@@ -3033,7 +3032,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
content="Checked the repo.", content="Checked the repo.",
) )
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
async def run(self) -> None: async def run(self) -> None:
@@ -3253,7 +3252,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
self.runtime_resolver.invalidate.assert_called_once_with() self.runtime_resolver.invalidate.assert_called_once_with()
await asyncio.Event().wait() await asyncio.Event().wait()
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
def stop(self) -> None: def stop(self) -> None:
@@ -3499,7 +3498,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def run(self) -> None: async def run(self) -> None:
await asyncio.Event().wait() await asyncio.Event().wait()
async def close_mcp(self) -> None: async def aclose(self) -> None:
return None return None
def stop(self) -> None: def stop(self) -> None:
@@ -3668,7 +3667,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
assert timed_out_writer.output == b"" assert timed_out_writer.output == b""
def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup( def test_gateway_agent_task_owns_initial_mcp_provider_close(
monkeypatch, monkeypatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -3696,17 +3695,41 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
return None return None
async def run(self) -> None: async def run(self) -> None:
seen["agent_task"] = asyncio.current_task()
try: try:
await asyncio.Event().wait() await asyncio.Event().wait()
finally: finally:
seen["agent_task_cleaned_up"] = True seen["agent_task_cleaned_up"] = True
async def close_mcp(self) -> None: async def aclose(self) -> None:
raise AssertionError("gateway must not close MCP from the outer task") seen["agent_closed"] = True
def stop(self) -> None: def stop(self) -> None:
seen["agent_stopped"] = True seen["agent_stopped"] = True
class _FakeMCPProvider:
def __init__(self) -> None:
self.connect_task: asyncio.Task | None = None
self.close_tasks: list[asyncio.Task | None] = []
@classmethod
def from_config(cls, _config, _registry):
provider = cls()
seen["mcp_provider"] = provider
return provider
async def connect(self) -> None:
self.connect_task = asyncio.current_task()
async def aclose(self) -> None:
self.close_tasks.append(asyncio.current_task())
def runtime_status(self) -> dict[str, str]:
return {}
async def reload(self) -> dict[str, object]:
return {"ok": True}
class _FakeChannelManager: class _FakeChannelManager:
def __init__(self, _config, _bus, **_kwargs) -> None: def __init__(self, _config, _bus, **_kwargs) -> None:
self.enabled_channels = ["telegram"] self.enabled_channels = ["telegram"]
@@ -3753,6 +3776,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
session_manager=lambda _workspace: object(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server) monkeypatch.setattr("asyncio.start_server", _fake_start_server)
@@ -3761,9 +3785,15 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
assert result.exit_code == 0 assert result.exit_code == 0
assert seen["agent_stopped"] is True assert seen["agent_stopped"] is True
assert seen["agent_closed"] is True
assert seen["agent_task_cleaned_up"] is True assert seen["agent_task_cleaned_up"] is True
assert seen["channels_stopped"] is True assert seen["channels_stopped"] is True
assert seen["cron_stopped"] is True assert seen["cron_stopped"] is True
mcp_provider = seen["mcp_provider"]
assert isinstance(mcp_provider, _FakeMCPProvider)
assert mcp_provider.connect_task is seen["agent_task"]
assert mcp_provider.close_tasks[0] is mcp_provider.connect_task
assert len(mcp_provider.close_tasks) == 2
def test_gateway_shutdown_event_exits_forever_runtime_tasks( def test_gateway_shutdown_event_exits_forever_runtime_tasks(
@@ -3800,8 +3830,8 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
finally: finally:
seen["agent_task_cleaned_up"] = True seen["agent_task_cleaned_up"] = True
async def close_mcp(self) -> None: async def aclose(self) -> None:
raise AssertionError("gateway must not close MCP from the outer task") seen["agent_closed"] = True
def stop(self) -> None: def stop(self) -> None:
seen["agent_stopped"] = True seen["agent_stopped"] = True
@@ -3881,6 +3911,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
assert result.exit_code == 0 assert result.exit_code == 0
assert seen["agent_stopped"] is True assert seen["agent_stopped"] is True
assert seen["agent_closed"] is True
assert seen["agent_task_cleaned_up"] is True assert seen["agent_task_cleaned_up"] is True
assert seen["channel_task_cleaned_up"] is True assert seen["channel_task_cleaned_up"] is True
assert seen["channels_stopped"] is True assert seen["channels_stopped"] is True
+43 -11
View File
@@ -22,7 +22,7 @@ class _FakeAgent:
self.raise_on_close = False self.raise_on_close = False
self.background: asyncio.Task[None] | None = None self.background: asyncio.Task[None] | None = None
async def close_mcp(self) -> None: async def aclose(self) -> None:
self.close_calls += 1 self.close_calls += 1
if self.hang_on_close: if self.hang_on_close:
await asyncio.sleep(3600) await asyncio.sleep(3600)
@@ -30,7 +30,7 @@ class _FakeAgent:
raise RuntimeError("cleanup exploded") raise RuntimeError("cleanup exploded")
if self.background is not None: if self.background is not None:
await self.background await self.background
self.events.append("close_mcp") self.events.append("aclose")
class _FakeChannels: class _FakeChannels:
@@ -43,6 +43,16 @@ class _FakeChannels:
self.events.append("channels_stopped") self.events.append("channels_stopped")
class _FakeMCPProvider:
def __init__(self, events: list[str] | None = None) -> None:
self.close_calls = 0
self.events = events if events is not None else []
async def aclose(self) -> None:
self.close_calls += 1
self.events.append("mcp_closed")
async def _cancellable_task(events: list[str]) -> None: async def _cancellable_task(events: list[str]) -> None:
try: try:
await asyncio.sleep(3600) await asyncio.sleep(3600)
@@ -64,13 +74,14 @@ async def _stubborn_task(events: list[str]) -> None:
async def test_runtime_tasks_cancelled_before_resources_closed() -> None: async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
events: list[str] = [] events: list[str] = []
agent = _FakeAgent(events) agent = _FakeAgent(events)
provider = _FakeMCPProvider(events)
channels = _FakeChannels() channels = _FakeChannels()
task = asyncio.create_task(_cancellable_task(events)) task = asyncio.create_task(_cancellable_task(events))
await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body) await asyncio.sleep(0) # let the task start (cancellation pre-start skips its body)
await _close_gateway_runtime(agent, channels, [task], None) await _close_gateway_runtime(agent, provider, channels, [task], None)
assert events == ["cancelled", "close_mcp"] # cancel happens before close assert events == ["cancelled", "aclose", "mcp_closed"]
assert channels.stopped == 1 assert channels.stopped == 1
assert agent.close_calls == 1 assert agent.close_calls == 1
assert task.cancelled() assert task.cancelled()
@@ -78,6 +89,7 @@ async def test_runtime_tasks_cancelled_before_resources_closed() -> None:
async def test_pending_background_work_is_drained_before_close_returns() -> None: async def test_pending_background_work_is_drained_before_close_returns() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
channels = _FakeChannels() channels = _FakeChannels()
done: dict[str, bool] = {"done": False} done: dict[str, bool] = {"done": False}
@@ -87,7 +99,7 @@ async def test_pending_background_work_is_drained_before_close_returns() -> None
agent.background = asyncio.create_task(background_work()) agent.background = asyncio.create_task(background_work())
await _close_gateway_runtime(agent, channels, [], None) await _close_gateway_runtime(agent, provider, channels, [], None)
assert done["done"] is True assert done["done"] is True
assert agent.close_calls == 1 assert agent.close_calls == 1
@@ -95,6 +107,7 @@ async def test_pending_background_work_is_drained_before_close_returns() -> None
async def test_stubborn_task_does_not_block_past_wait_timeout() -> None: async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
channels = _FakeChannels() channels = _FakeChannels()
events: list[str] = [] events: list[str] = []
task = asyncio.create_task(_stubborn_task(events)) task = asyncio.create_task(_stubborn_task(events))
@@ -104,6 +117,7 @@ async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
start = time.monotonic() start = time.monotonic()
await _close_gateway_runtime( await _close_gateway_runtime(
agent, agent,
provider,
channels, channels,
[task], [task],
runtime_tasks, runtime_tasks,
@@ -117,70 +131,88 @@ async def test_stubborn_task_does_not_block_past_wait_timeout() -> None:
assert task.done() # the timed-out task received a second cancellation assert task.done() # the timed-out task received a second cancellation
assert runtime_tasks.done() assert runtime_tasks.done()
assert agent.close_calls == 1 # resources still closed underneath it assert agent.close_calls == 1 # resources still closed underneath it
assert provider.close_calls == 1
assert elapsed < 1.0 # bounded, not held open by the stubborn task assert elapsed < 1.0 # bounded, not held open by the stubborn task
async def test_hanging_close_is_bounded_and_does_not_raise() -> None: async def test_hanging_close_is_bounded_and_does_not_raise() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
agent.hang_on_close = True agent.hang_on_close = True
channels = _FakeChannels() channels = _FakeChannels()
start = time.monotonic() start = time.monotonic()
await _close_gateway_runtime(agent, channels, [], None, close_timeout=0.05) await _close_gateway_runtime(
agent,
provider,
channels,
[],
None,
close_timeout=0.05,
)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
assert agent.close_calls == 1 assert agent.close_calls == 1
assert provider.close_calls == 1
assert channels.stopped == 1 assert channels.stopped == 1
assert elapsed < 1.0 assert elapsed < 1.0
async def test_failing_close_is_logged_but_shutdown_proceeds() -> None: async def test_failing_close_is_logged_but_shutdown_proceeds() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
agent.raise_on_close = True agent.raise_on_close = True
channels = _FakeChannels() channels = _FakeChannels()
await _close_gateway_runtime(agent, channels, [], None) await _close_gateway_runtime(agent, provider, channels, [], None)
assert agent.close_calls == 1 assert agent.close_calls == 1
assert provider.close_calls == 1
assert channels.stopped == 1 # teardown continued past the failure assert channels.stopped == 1 # teardown continued past the failure
async def test_duplicate_cleanup_is_idempotent() -> None: async def test_duplicate_cleanup_is_idempotent() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
channels = _FakeChannels() channels = _FakeChannels()
task = asyncio.create_task(_cancellable_task([])) task = asyncio.create_task(_cancellable_task([]))
await _close_gateway_runtime(agent, channels, [task], None) await _close_gateway_runtime(agent, provider, channels, [task], None)
await _close_gateway_runtime(agent, channels, [task], None) await _close_gateway_runtime(agent, provider, channels, [task], None)
assert agent.close_calls == 2 # second pass is a clean no-op assert agent.close_calls == 2 # second pass is a clean no-op
assert provider.close_calls == 2
assert channels.stopped == 2 assert channels.stopped == 2
assert task.cancelled() assert task.cancelled()
async def test_finished_runtime_tasks_gather_is_retrieved() -> None: async def test_finished_runtime_tasks_gather_is_retrieved() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
channels = _FakeChannels() channels = _FakeChannels()
finished = asyncio.get_running_loop().create_future() finished = asyncio.get_running_loop().create_future()
finished.set_result(None) finished.set_result(None)
runtime_tasks = asyncio.gather(finished) runtime_tasks = asyncio.gather(finished)
await asyncio.sleep(0) # let the gather observe the finished child await asyncio.sleep(0) # let the gather observe the finished child
await _close_gateway_runtime(agent, channels, [], runtime_tasks) await _close_gateway_runtime(agent, provider, channels, [], runtime_tasks)
assert runtime_tasks.done() assert runtime_tasks.done()
assert agent.close_calls == 1 assert agent.close_calls == 1
assert provider.close_calls == 1
async def test_cancelled_runtime_tasks_gather_does_not_raise() -> None: async def test_cancelled_runtime_tasks_gather_does_not_raise() -> None:
agent = _FakeAgent() agent = _FakeAgent()
provider = _FakeMCPProvider()
channels = _FakeChannels() channels = _FakeChannels()
runtime_tasks = asyncio.gather(asyncio.sleep(3600)) runtime_tasks = asyncio.gather(asyncio.sleep(3600))
runtime_tasks.cancel() runtime_tasks.cancel()
await _close_gateway_runtime(agent, channels, [], runtime_tasks) await _close_gateway_runtime(agent, provider, channels, [], runtime_tasks)
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await runtime_tasks # settle the cancelled gather without raising await runtime_tasks # settle the cancelled gather without raising
assert runtime_tasks.done() # the cancelled gather was awaited without raising assert runtime_tasks.done() # the cancelled gather was awaited without raising
assert agent.close_calls == 1 assert agent.close_calls == 1
assert provider.close_calls == 1
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from typer.testing import CliRunner
from nanobot.cli import commands
from nanobot.config.loader import load_config
from nanobot.session.manager import SessionManager
def test_sessions_restore_workspace_command_prepares_downgrade(
tmp_path: Path,
monkeypatch,
) -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "instance" / "config.json"
config = load_config(config_path)
config.agents.defaults.workspace = str(workspace)
manager = SessionManager(workspace, sessions_root=config_path.parent / "sessions")
session = manager.get_or_create("cli:rollback")
session.add_message("user", "restore-me")
manager.save(session, fsync=True)
monkeypatch.setattr(commands, "_load_runtime_config", lambda *_args: config)
result = CliRunner().invoke(commands.app, ["sessions", "restore-workspace"])
assert result.exit_code == 0, result.output
assert "Restored 1 session file(s)" in result.output
restored = workspace / "sessions" / manager._get_session_path(session.key).name
assert restored.exists()
assert "restore-me" in restored.read_text(encoding="utf-8")
assert manager._get_session_path(session.key).exists()
+3
View File
@@ -442,12 +442,15 @@ def test_run_argv_logs_command_exit_and_output(
encoding: str, encoding: str,
errors: str, errors: str,
timeout: int, timeout: int,
env: dict[str, str],
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
assert capture_output is True assert capture_output is True
assert text is True assert text is True
assert encoding == "utf-8" assert encoding == "utf-8"
assert errors == "replace" assert errors == "replace"
assert timeout == 5 assert timeout == 5
assert "OPENAI_API_KEY" not in env
assert env["PYTHONUNBUFFERED"] == "1"
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="") return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
monkeypatch.setattr(cli_service, "logger", _Logger()) monkeypatch.setattr(cli_service, "logger", _Logger())
+17 -2
View File
@@ -3,14 +3,29 @@ import json
import pytest import pytest
from nanobot.config.errors import ConfigLoadError from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import load_config from nanobot.config.loader import load_config, resolve_config_env_vars
from nanobot.config.schema import ApiConfig from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None: def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
config = load_config(tmp_path / "missing.json") config_path = tmp_path / "instance" / "missing.json"
config = load_config(config_path)
assert config.agents.defaults.model assert config.agents.defaults.model
assert config.runtime_data_dir == config_path.parent
def test_env_resolution_preserves_config_runtime_data_dir(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "instance" / "config.json"
config_path.parent.mkdir()
config_path.write_text('{"providers": {"openai": {"apiKey": "${TEST_API_KEY}"}}}')
monkeypatch.setenv("TEST_API_KEY", "resolved")
config = resolve_config_env_vars(load_config(config_path), config_path=config_path)
assert config.runtime_data_dir == config_path.parent
def test_load_config_reports_malformed_environment_safely( def test_load_config_reports_malformed_environment_safely(
+34 -2
View File
@@ -299,8 +299,8 @@ def _fake_chat_stream_legacy_function_call_chunks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None: async def test_openai_compat_chat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming.""" """DeepSeek Chat Completions exposes ``delta.reasoning_content`` while streaming."""
mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks()) mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks())
spec = find_by_name("deepseek") spec = find_by_name("deepseek")
thinking: list[str] = [] thinking: list[str] = []
@@ -321,6 +321,7 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
default_model="deepseek-v4-pro", default_model="deepseek-v4-pro",
spec=spec, spec=spec,
) )
provider._api_type = "chat_completions"
result = await provider.chat_stream( result = await provider.chat_stream(
messages=[{"role": "user", "content": "hi"}], messages=[{"role": "user", "content": "hi"}],
model="deepseek-v4-pro", model="deepseek-v4-pro",
@@ -336,6 +337,37 @@ async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -
mock_chat.assert_awaited_once() mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_deepseek_v4_pro_uses_responses_api() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test",
default_model="deepseek-v4-pro",
spec=find_by_name("deepseek"),
)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="deepseek-v4-pro",
reasoning_effort="none",
)
assert result.content == "from responses"
mock_responses.assert_awaited_once()
mock_chat.assert_not_awaited()
call_kwargs = mock_responses.call_args.kwargs
assert call_kwargs["model"] == "deepseek-v4-pro"
assert call_kwargs["reasoning"] == {"effort": "none"}
assert call_kwargs["tools"] == [{"type": "web_search"}]
assert "include" not in call_kwargs
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
("provider_name", "model"), ("provider_name", "model"),
@@ -11,6 +11,7 @@ from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider, OpenAICompatProvider,
) )
from nanobot.providers.openai_responses.state import build_responses_state from nanobot.providers.openai_responses.state import build_responses_state
from nanobot.providers.registry import find_by_name
@pytest.fixture() @pytest.fixture()
@@ -30,30 +31,22 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True assert provider._should_use_responses_api("gpt-5", None) is True
def test_deepseek_v4_flash_uses_responses_by_model(provider): @pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
provider._spec = type("Spec", (), { def test_deepseek_v4_models_use_responses_by_model(provider, model):
"name": "deepseek", provider._spec = find_by_name("deepseek")
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._effective_base = "https://api.deepseek.com" provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash" provider.default_model = model
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True assert provider._should_use_responses_api(model, None) is True
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False assert provider._should_use_responses_api("deepseek-chat", None) is False
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider): @pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"])
provider._spec = type("Spec", (), { def test_deepseek_v4_models_match_provider_prefixed_model(provider, model):
"name": "deepseek", provider._spec = find_by_name("deepseek")
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._effective_base = "https://api.deepseek.com" provider._effective_base = "https://api.deepseek.com"
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True assert provider._should_use_responses_api(f"deepseek/{model}", None) is True
def test_direct_openai_enables_server_compaction(provider): def test_direct_openai_enables_server_compaction(provider):
+330
View File
@@ -0,0 +1,330 @@
"""Session storage location: outside the agent workspace (ADR-0001)."""
from __future__ import annotations
import json
import shutil
import uuid
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.config.loader import load_config
from nanobot.session.manager import JsonlSessionStore, SessionManager
def _write_legacy_session(
old_dir: Path,
key: str,
content: str,
*,
updated_at: str = "2026-01-01T00:00:00",
) -> Path:
"""Write a valid session file in the legacy in-workspace location."""
old_dir.mkdir(parents=True, exist_ok=True)
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
path.write_text(
json.dumps(
{
"_type": "metadata",
"key": key,
"created_at": "2026-01-01T00:00:00",
"updated_at": updated_at,
"metadata": {},
"last_consolidated": 0,
}
)
+ "\n"
+ json.dumps({"role": "user", "content": content})
+ "\n",
encoding="utf-8",
)
return path
def test_sessions_are_stored_outside_workspace(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "hello")
manager.save(session)
# The session file must NOT live inside the workspace.
workspace_sessions = workspace / "sessions"
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
# The out-of-workspace store records which workspace it belongs to and the
# workspace carries only a non-secret stable identity marker.
marker = manager.sessions_dir / ".workspace"
assert marker.read_text(encoding="utf-8").strip() == str(workspace.resolve())
workspace_id = (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip()
assert manager.sessions_dir.name == workspace_id
assert manager.sessions_dir.parent.name == "sessions"
# And it must still round-trip through a fresh manager for the same workspace.
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
assert reloaded.messages[-1]["content"] == "hello"
def test_workspace_identity_marker_contains_no_session_content(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
secret = f"session-secret-{uuid.uuid4()}"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:secret")
session.add_message("user", secret)
manager.save(session)
marker = workspace / ".nanobot" / "workspace-id"
assert marker.read_text(encoding="utf-8").strip() == manager.sessions_dir.name
assert secret not in marker.read_text(encoding="utf-8")
assert not any(
secret in path.read_text(encoding="utf-8")
for path in workspace.rglob("*")
if path.is_file()
)
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
workspace_a = tmp_path / "ws_a"
workspace_b = tmp_path / "ws_b"
manager_a = SessionManager(workspace=workspace_a)
session = manager_a.get_or_create("telegram:1")
session.add_message("user", "secret-for-a")
manager_a.save(session)
# A second workspace must not see A's session.
assert manager_a.sessions_dir != SessionManager(workspace=workspace_b).sessions_dir
in_b = SessionManager(workspace=workspace_b).get_or_create("telegram:1")
assert in_b.messages == []
def test_sessions_follow_active_custom_config_data_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
custom_instance = tmp_path / "instance-b"
custom_config = custom_instance / "config.json"
default_home = tmp_path / "read-only-home"
default_home.mkdir()
default_home.chmod(0o500)
monkeypatch.setenv("HOME", str(default_home))
config = load_config(custom_config)
data_dir = config.runtime_data_dir
assert data_dir == custom_instance
manager = SessionManager(
workspace=tmp_path / "workspace-b",
sessions_root=data_dir / "sessions",
)
session = manager.get_or_create("telegram:custom")
session.add_message("user", "custom-instance")
manager.save(session)
assert manager.sessions_dir.parent == custom_instance / "sessions"
assert manager._get_session_path(session.key).exists()
assert not (default_home / ".nanobot" / "sessions").exists()
def test_session_root_inside_workspace_fails_closed(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
with pytest.raises(RuntimeError, match="must be outside the agent workspace"):
SessionManager(workspace=workspace, sessions_root=workspace / "sessions")
def test_workspace_move_preserves_session_identity(tmp_path: Path) -> None:
original = tmp_path / "project-old"
manager = SessionManager(workspace=original)
session = manager.get_or_create("telegram:1")
session.add_message("user", "survives-move")
manager.save(session)
moved = tmp_path / "project-new"
original.rename(moved)
reloaded = SessionManager(workspace=moved)
assert reloaded.sessions_dir == manager.sessions_dir
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-move"
assert (reloaded.sessions_dir / ".workspace").read_text(encoding="utf-8").strip() == str(
moved.resolve()
)
def test_deleted_workspace_identity_marker_is_recovered(tmp_path: Path) -> None:
workspace = tmp_path / "project"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "survives-cleanup")
manager.save(session)
shutil.rmtree(workspace / ".nanobot")
reloaded = SessionManager(workspace=workspace)
assert reloaded.sessions_dir == manager.sessions_dir
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-cleanup"
assert (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip() == (
manager.sessions_dir.name
)
def test_copied_workspace_gets_isolated_session_identity(tmp_path: Path) -> None:
original = tmp_path / "project-a"
original.mkdir()
manager = SessionManager(workspace=original)
session = manager.get_or_create("telegram:1")
session.add_message("user", "secret-for-a")
manager.save(session)
copied = tmp_path / "project-b"
shutil.copytree(original, copied)
copied_manager = SessionManager(workspace=copied)
assert copied_manager.sessions_dir != manager.sessions_dir
assert copied_manager.get_or_create("telegram:1").messages == []
assert (copied / ".nanobot" / "workspace-id").read_text(encoding="utf-8") != (
original / ".nanobot" / "workspace-id"
).read_text(encoding="utf-8")
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
real_workspace = tmp_path / "real_ws"
real_workspace.mkdir()
link_workspace = tmp_path / "link_ws"
link_workspace.symlink_to(real_workspace, target_is_directory=True)
# Save via the real path, then read via a symlink to the same directory.
manager = SessionManager(workspace=real_workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "via-real")
manager.save(session)
via_link = SessionManager(workspace=link_workspace).get_or_create("telegram:1")
assert via_link.messages[-1]["content"] == "via-real"
def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:1"
old_file = _write_legacy_session(workspace / "sessions", key, "migrated-msg")
manager = SessionManager(workspace=workspace)
# The session is readable through the normal store.
loaded = manager.get_or_create(key)
assert loaded.messages[-1]["content"] == "migrated-msg"
# The legacy in-workspace file has been moved away...
assert not old_file.exists()
# ...into the out-of-workspace store.
assert (manager.sessions_dir / old_file.name).exists()
# Migration is idempotent: a second construction must not corrupt anything.
again = SessionManager(workspace=workspace).get_or_create(key)
assert again.messages[-1]["content"] == "migrated-msg"
def test_migration_keeps_source_when_install_fails(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:partial"
old_file = _write_legacy_session(workspace / "sessions", key, "still-safe")
with patch.object(JsonlSessionStore, "_install_snapshot", side_effect=OSError("disk full")):
manager = SessionManager(workspace=workspace)
assert old_file.exists()
assert not (manager.sessions_dir / old_file.name).exists()
retried = SessionManager(workspace=workspace)
assert retried.get_or_create(key).messages[-1]["content"] == "still-safe"
assert not old_file.exists()
def test_migration_preserves_newest_valid_conflict(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:conflict"
old_file = _write_legacy_session(workspace / "sessions", key, "newer-workspace")
manager = SessionManager(workspace=workspace)
# Recreate an older legacy source while a newer destination already exists.
manager_session = manager.get_or_create(key)
manager_session.add_message("assistant", "newer-destination")
manager.save(manager_session)
_write_legacy_session(
workspace / "sessions",
key,
"older-workspace",
updated_at="2025-01-01T00:00:00",
)
retried = SessionManager(workspace=workspace)
loaded = retried.get_or_create(key)
assert loaded.messages[-1]["content"] == "newer-destination"
conflicts = list((retried.sessions_dir / ".migration-conflicts").glob("*.jsonl"))
assert len(conflicts) == 1
assert "older-workspace" in conflicts[0].read_text(encoding="utf-8")
assert not old_file.exists()
def test_explicit_rollback_restore_copies_sessions_back_without_deleting_new_store(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:rollback")
session.add_message("user", "available-to-old-version")
manager.save(session, fsync=True)
result = manager.restore_sessions_to_workspace()
legacy_file = workspace / "sessions" / manager._get_session_path(session.key).name
assert result.restored == 1
assert result.unchanged == 0
assert result.conflicts == ()
assert legacy_file.exists()
assert manager._get_session_path(session.key).exists()
assert "available-to-old-version" in legacy_file.read_text(encoding="utf-8")
repeated = manager.restore_sessions_to_workspace()
assert repeated.restored == 0
assert repeated.unchanged == 1
assert repeated.conflicts == ()
def test_legacy_migration_rejects_symlinked_session_file(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
old_dir = workspace / "sessions"
old_dir.mkdir(parents=True)
key = "telegram:symlink"
outside = _write_legacy_session(tmp_path / "outside", key, "outside-secret")
source = old_dir / outside.name
try:
source.symlink_to(outside)
except OSError as exc:
pytest.skip(f"file symlink unavailable: {exc}")
manager = SessionManager(workspace=workspace)
assert source.is_symlink()
assert not (manager.sessions_dir / source.name).exists()
assert manager.get_or_create(key).messages == []
def test_legacy_migration_rejects_symlinked_sessions_directory(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
key = "telegram:directory-symlink"
outside_file = _write_legacy_session(outside, key, "outside-secret")
workspace.mkdir()
try:
(workspace / "sessions").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
manager = SessionManager(workspace=workspace)
assert outside_file.exists()
assert not (manager.sessions_dir / outside_file.name).exists()
assert manager.get_or_create(key).messages == []
+1 -2
View File
@@ -32,8 +32,7 @@ AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def _make_mock_agent(response_text: str = "mock response") -> MagicMock: def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent = MagicMock() agent = MagicMock()
agent.process_direct = AsyncMock(return_value=response_text) agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
return agent return agent
+8 -16
View File
@@ -64,8 +64,7 @@ def test_sse_done_format() -> None:
def _make_streaming_agent(tokens: list[str]) -> MagicMock: def _make_streaming_agent(tokens: list[str]) -> MagicMock:
"""Create a mock agent that streams tokens via on_stream callback.""" """Create a mock agent that streams tokens via on_stream callback."""
agent = MagicMock() agent = MagicMock()
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
async def fake_process_direct(*, content="", media=None, session_key="", async def fake_process_direct(*, content="", media=None, session_key="",
channel="", chat_id="", on_stream=None, channel="", chat_id="", on_stream=None,
@@ -136,8 +135,7 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
"""stream=false should still return regular JSON response.""" """stream=false should still return regular JSON response."""
agent = MagicMock() agent = MagicMock()
agent.process_direct = AsyncMock(return_value="normal reply") agent.process_direct = AsyncMock(return_value="normal reply")
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -160,8 +158,7 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
"""Omitting stream should behave like stream=false.""" """Omitting stream should behave like stream=false."""
agent = MagicMock() agent = MagicMock()
agent.process_direct = AsyncMock(return_value="default reply") agent.process_direct = AsyncMock(return_value="default reply")
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -217,8 +214,7 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
agent = MagicMock() agent = MagicMock()
agent.process_direct = fake_process_direct agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -251,8 +247,7 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
return "planning final" return "planning final"
agent.process_direct = fake_process_direct agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -291,8 +286,7 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
return "plain final" return "plain final"
agent.process_direct = fake_process_direct agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -334,8 +328,7 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
agent = MagicMock() agent = MagicMock()
agent.process_direct = fake_process_direct agent.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -364,8 +357,7 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
raise RuntimeError("backend blew up") raise RuntimeError("backend blew up")
agent.process_direct = boom agent.process_direct = boom
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
+30 -10
View File
@@ -46,7 +46,9 @@ def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
} }
if overrides: if overrides:
data.update(overrides) data.update(overrides)
config_path = tmp_path / "config.json" config_dir = tmp_path.parent / f"{tmp_path.name}-instance"
config_dir.mkdir(exist_ok=True)
config_path = config_dir / "config.json"
config_path.write_text(json.dumps(data)) config_path.write_text(json.dumps(data))
return config_path return config_path
@@ -96,9 +98,24 @@ def test_from_config_missing_env_reports_explicit_config_path(
def test_from_config_creates_instance(tmp_path): def test_from_config_creates_instance(tmp_path):
config_path = _write_config(tmp_path) config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path) workspace = tmp_path / "workspace"
bot = Nanobot.from_config(config_path, workspace=workspace)
assert bot._loop is not None assert bot._loop is not None
assert bot._loop.workspace == tmp_path assert bot._loop.workspace == workspace
assert bot._loop.sessions.sessions_dir.parent == config_path.parent / "sessions"
def test_from_config_composes_configured_mcp_outside_agent_loop(tmp_path):
config_path = _write_config(
tmp_path,
{"tools": {"mcpServers": {"demo": {"command": "fake-mcp"}}}},
)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
assert bot._mcp_provider is not None
assert bot._mcp_provider.configured_server_names == {"demo"}
assert bot._mcp_provider._registry is bot._loop.tools
def test_from_config_accepts_default_model_override(tmp_path): def test_from_config_accepts_default_model_override(tmp_path):
@@ -1637,37 +1654,40 @@ async def test_runtime_helpers_expose_model_workspace_and_compact(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_aclose_delegates_to_loop_close_mcp(tmp_path): async def test_aclose_releases_loop_and_mcp_provider(tmp_path):
config_path = _write_config(tmp_path) config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path) bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock() bot._loop.aclose = AsyncMock()
assert bot._mcp_provider is not None
bot._mcp_provider.aclose = AsyncMock()
await bot.aclose() await bot.aclose()
bot._loop.close_mcp.assert_awaited_once() bot._loop.aclose.assert_awaited_once()
bot._mcp_provider.aclose.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_context_manager_calls_aclose_on_exit(tmp_path): async def test_context_manager_calls_aclose_on_exit(tmp_path):
config_path = _write_config(tmp_path) config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path) bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock() bot._loop.aclose = AsyncMock()
async with bot as b: async with bot as b:
assert b is bot assert b is bot
bot._loop.close_mcp.assert_awaited_once() bot._loop.aclose.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_context_manager_does_not_swallow_exceptions(tmp_path): async def test_context_manager_does_not_swallow_exceptions(tmp_path):
config_path = _write_config(tmp_path) config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path) bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock() bot._loop.aclose = AsyncMock()
with pytest.raises(ValueError): with pytest.raises(ValueError):
async with bot as b: async with bot as b:
assert b is bot assert b is bot
raise ValueError("boom") raise ValueError("boom")
bot._loop.close_mcp.assert_awaited_once() bot._loop.aclose.assert_awaited_once()
+57 -9
View File
@@ -34,8 +34,7 @@ AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def _make_mock_agent(response_text: str = "mock response") -> MagicMock: def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent = MagicMock() agent = MagicMock()
agent.process_direct = AsyncMock(return_value=response_text) agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50} agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
return agent return agent
@@ -149,6 +148,59 @@ async def test_api_routes_allow_requests_without_configured_api_key(aiohttp_clie
mock_agent.process_direct.assert_called_once() mock_agent.process_direct.assert_called_once()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_api_prepares_application_resources_before_each_turn(aiohttp_client) -> None:
events: list[str] = []
agent = _make_mock_agent()
async def prepare_agent() -> None:
events.append("prepare")
async def process_direct(**_kwargs):
events.append("process")
return "ready"
agent.process_direct = process_direct
app = create_app(agent, prepare_agent=prepare_agent)
client = await aiohttp_client(app)
response = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert response.status == 200
assert events == ["prepare", "process"]
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_api_preparation_is_bounded_by_request_timeout(aiohttp_client) -> None:
agent = _make_mock_agent()
started = asyncio.Event()
async def prepare_agent() -> None:
started.set()
await asyncio.Event().wait()
app = create_app(
agent,
request_timeout=0.01,
prepare_agent=prepare_agent,
)
client = await aiohttp_client(app)
response = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert started.is_set()
assert response.status == 504
agent.process_direct.assert_not_awaited()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_user_message_returns_400(aiohttp_client, app) -> None: async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
@@ -275,8 +327,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
agent = MagicMock() agent = MagicMock()
agent.process_direct = fake_process agent.process_direct = fake_process
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -315,8 +366,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
agent = MagicMock() agent = MagicMock()
agent.process_direct = slow_process agent.process_direct = slow_process
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -433,8 +483,7 @@ async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
agent = MagicMock() agent = MagicMock()
agent.process_direct = always_empty agent.process_direct = always_empty
agent._connect_mcp = AsyncMock() agent.aclose = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {} agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY) app = create_app(agent, model_name="m", api_key=API_KEY)
@@ -457,7 +506,6 @@ async def test_process_direct_accepts_media() -> None:
from nanobot.bus.runtime_events import RuntimeEventPublisher from nanobot.bus.runtime_events import RuntimeEventPublisher
loop = AgentLoop.__new__(AgentLoop) loop = AgentLoop.__new__(AgentLoop)
loop._connect_mcp = AsyncMock()
loop._session_locks = {} loop._session_locks = {}
loop.runtime_event_publisher = RuntimeEventPublisher() loop.runtime_event_publisher = RuntimeEventPublisher()
+25
View File
@@ -76,6 +76,31 @@ def test_guard_allow_patterns_block_single_ampersand_chained_segment():
assert "allowlist" in result.lower() assert "allowlist" in result.lower()
def test_guard_allow_patterns_block_newline_chained_segment():
"""A newline separates commands, so each line must match on its own."""
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"])
result = tool._guard_command("echo allowlisted\ntouch /tmp/evil", "/tmp")
assert result is not None
assert "allowlist" in result.lower()
def test_guard_newline_chained_segment_still_hits_deny_patterns():
"""An allowlisted first line does not exempt a denied later line."""
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"])
result = tool._guard_command("echo allowlisted\nrm -rf /", "/tmp")
assert result is not None
assert "deny pattern filter" in result.lower()
def test_split_shell_segments_keep_line_continuation_intact():
"""A backslash-escaped newline continues one command, not a new segment."""
assert ExecTool._split_shell_segments("echo allowlisted \\\nextra") == [
"echo allowlisted \\\nextra"
]
def test_guard_allow_patterns_preserve_trailing_background_operator(): def test_guard_allow_patterns_preserve_trailing_background_operator():
tool = ExecTool(allow_patterns=[r"echo\s+allowlisted"]) tool = ExecTool(allow_patterns=[r"echo\s+allowlisted"])
+62 -7
View File
@@ -8,7 +8,7 @@ platform-specific binaries (all subprocess calls are mocked).
import asyncio import asyncio
import shutil import shutil
import sys import sys
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -21,6 +21,19 @@ _WINDOWS_ENV_KEYS = {
} }
class _FakeWindowsJob:
creation_flags = 0
def assign_and_resume(self, pid: int) -> None:
pass
def release(self) -> None:
pass
def terminate(self) -> None:
pass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _build_env # _build_env
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -133,6 +146,32 @@ class TestSpawnUnix:
class TestSpawnWindows: class TestSpawnWindows:
@pytest.mark.asyncio
async def test_job_assignment_failure_kills_suspended_process(self):
env = {"PATH": ""}
process = AsyncMock()
process.pid = 123
process.returncode = None
process.kill = MagicMock()
process.wait.return_value = -9
job = MagicMock(spec=_FakeWindowsJob)
job.creation_flags = 0x4
job.assign_and_resume.side_effect = OSError("OpenProcess failed")
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("nanobot.agent.tools.shell.sys", MagicMock(platform="win32")),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch.object(ExecTool, "_create_windows_job", return_value=job),
pytest.raises(OSError, match="OpenProcess failed"),
):
mock_exec.return_value = process
await ExecTool._spawn("echo hi", r"C:\work", env, process_tree=True)
job.terminate.assert_called_once_with()
process.kill.assert_called_once_with()
process.wait.assert_awaited_once_with()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_single_line_uses_powershell(self): async def test_single_line_uses_powershell(self):
"""Single-line commands on Windows now route through PowerShell.""" """Single-line commands on Windows now route through PowerShell."""
@@ -302,7 +341,9 @@ class TestPathAppendPlatform:
captured_cmd = None captured_cmd = None
captured_env = {} captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=True, *, process_tree=False,
):
nonlocal captured_cmd nonlocal captured_cmd
captured_cmd = cmd captured_cmd = cmd
captured_env.update(env) captured_env.update(env)
@@ -331,7 +372,9 @@ class TestPathAppendPlatform:
captured_cmd = None captured_cmd = None
captured_env = {} captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
):
nonlocal captured_cmd nonlocal captured_cmd
captured_cmd = cmd captured_cmd = cmd
captured_env.update(env) captured_env.update(env)
@@ -359,7 +402,9 @@ class TestPathAppendPlatform:
captured_cmd = None captured_cmd = None
captured_env = {} captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
):
nonlocal captured_cmd nonlocal captured_cmd
captured_cmd = cmd captured_cmd = cmd
captured_env.update(env) captured_env.update(env)
@@ -389,7 +434,9 @@ class TestPathAppendPlatform:
captured_env = {} captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=True, *, process_tree=False,
):
captured_env.update(env) captured_env.update(env)
return mock_proc return mock_proc
@@ -412,7 +459,9 @@ class TestPathAppendPlatform:
captured_env = {} captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=True, *, stdin=None, process_tree=False,
):
captured_env.update(env) captured_env.update(env)
return mock_proc return mock_proc
@@ -558,7 +607,9 @@ class TestExecuteEndToEnd:
mock_proc.returncode = 0 mock_proc.returncode = 0
captured_login = [] captured_login = []
async def capture_spawn(cmd, cwd, env, shell_program=None, login=None, *, stdin=None): async def capture_spawn(
cmd, cwd, env, shell_program=None, login=None, *, stdin=None, process_tree=False,
):
captured_login.append(login) captured_login.append(login)
return mock_proc return mock_proc
@@ -649,6 +700,7 @@ class TestWindowsMultilineExec:
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
): ):
mock_exec.return_value = mock_proc mock_exec.return_value = mock_proc
@@ -670,6 +722,7 @@ class TestWindowsMultilineExec:
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
): ):
mock_exec.return_value = mock_proc mock_exec.return_value = mock_proc
@@ -733,6 +786,7 @@ class TestResolveShellWindows:
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
): ):
mock_exec.return_value = mock_proc mock_exec.return_value = mock_proc
@@ -754,6 +808,7 @@ class TestResolveShellWindows:
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell, patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
patch.object(ExecTool, "_create_windows_job", side_effect=_FakeWindowsJob),
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
): ):
mock_shell.return_value = mock_proc mock_shell.return_value = mock_proc
+196
View File
@@ -159,6 +159,7 @@ async def test_exec_blocks_chained_internal_url():
"cp /tmp/x memory/.dream_cursor", "cp /tmp/x memory/.dream_cursor",
], ],
) )
def test_exec_blocks_writes_to_history_jsonl(command): def test_exec_blocks_writes_to_history_jsonl(command):
"""Direct writes to history.jsonl / .dream_cursor must be blocked (#2989).""" """Direct writes to history.jsonl / .dream_cursor must be blocked (#2989)."""
tool = ExecTool() tool = ExecTool()
@@ -179,6 +180,7 @@ def test_exec_blocks_writes_to_history_jsonl(command):
"echo history.jsonl", "echo history.jsonl",
], ],
) )
def test_exec_allows_reads_of_history_jsonl(command): def test_exec_allows_reads_of_history_jsonl(command):
"""Read-only access to history.jsonl must still be allowed.""" """Read-only access to history.jsonl must still be allowed."""
tool = ExecTool() tool = ExecTool()
@@ -274,6 +276,7 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
"cat /dev/fd/3", "cat /dev/fd/3",
], ],
) )
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command): def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -426,6 +429,7 @@ def test_exec_bwrap_bind_parent_does_not_widen_workspace_guard(tmp_path, monkeyp
"|format", "|format",
], ],
) )
def test_exec_blocks_format_command(command): def test_exec_blocks_format_command(command):
"""The Windows ``format`` disk command must be denied.""" """The Windows ``format`` disk command must be denied."""
tool = ExecTool() tool = ExecTool()
@@ -445,6 +449,7 @@ def test_exec_blocks_format_command(command):
"echo reformat", "echo reformat",
], ],
) )
def test_exec_allows_format_in_url_and_args(command): def test_exec_allows_format_in_url_and_args(command):
"""``format`` inside URL parameters or as a non-command arg must be allowed.""" """``format`` inside URL parameters or as a non-command arg must be allowed."""
tool = ExecTool() tool = ExecTool()
@@ -496,3 +501,194 @@ def test_exec_blocks_outside_paths_from_subdirectory(tmp_path):
) )
assert result is not None assert result is not None
assert "path outside working dir" in result assert "path outside working dir" in result
def test_exec_blocks_outside_paths_with_redirection_and_delimiters(tmp_path):
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secrets"
outside.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
for cmd in (
f"cat<{outside / 'key.pem'}",
f"cat <{outside / 'key.pem'}",
f"({outside / 'key.pem'})",
f"cat {{{outside / 'key.pem'}}}",
):
result = tool._guard_command(cmd, str(workspace), workspace_root=str(workspace))
assert result is not None, f"Expected {cmd} to be blocked"
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and quoting semantics")
@pytest.mark.parametrize("quoted", [True, False])
def test_exec_does_not_truncate_parentheses_in_symlink_paths(tmp_path, quoted):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(outside / "secret.txt").write_text("secret")
link = workspace / "linked)dir"
link.symlink_to(outside, target_is_directory=True)
escaped_link = str(link).replace(")", r"\)")
rendered = f'"{link}/secret.txt"' if quoted else f"{escaped_link}/secret.txt"
command = f"cat {rendered}"
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert f"{link}/secret.txt" in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
def test_exec_checks_leaf_symlink_inside_command_substitution(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'cat "$(printf %s {link})"'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
@pytest.mark.parametrize(
("command", "not_a_posix_path"),
[
("curl https://example.com/outside/file", "/outside/file"),
("curl 'https://example.com/?next=/etc/passwd'", "/etc/passwd"),
("curl --url=https://example.com/?next=/etc/passwd", "/etc/passwd"),
("scp host:/etc/passwd .", "/etc/passwd"),
("echo C:/Windows/System32", "/Windows/System32"),
],
)
def test_exec_does_not_misclassify_nonlocal_slash_strings(command, not_a_posix_path):
assert not_a_posix_path not in ExecTool._extract_absolute_paths(command)
def test_exec_extracts_quoted_path_with_shell_punctuation():
path = "/tmp/a file)/with, punctuation"
assert ExecTool._extract_absolute_paths(f'cat "{path}"') == [path]
@pytest.mark.parametrize("uri", ["file:///etc/passwd", "file://localhost/%65tc/passwd"])
def test_exec_extracts_local_file_uri(uri):
assert "/etc/passwd" in ExecTool._extract_absolute_paths(f"curl {uri}")
def test_exec_blocks_file_uri_outside_workspace(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside file.txt"
workspace.mkdir()
outside.write_text("secret")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
result = tool._guard_command(
f"curl {outside.as_uri()}",
str(workspace),
workspace_root=str(workspace),
)
assert result is not None
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
def test_exec_checks_file_uri_leaf_symlink_inside_command_substitution(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'curl "$(printf file://{link})"'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
def test_exec_keeps_quoted_parenthesis_path_inside_workspace_allowed(tmp_path):
workspace = tmp_path / "workspace"
inside = workspace / "linked)dir" / "file.txt"
inside.parent.mkdir(parents=True)
inside.write_text("safe")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(
f'cat "{inside}"',
str(workspace),
workspace_root=str(workspace),
) is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and assignment semantics")
def test_exec_keeps_quoted_assignment_punctuation_inside_workspace_allowed(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(workspace / "linked").symlink_to(outside, target_is_directory=True)
inside = workspace / "linked;dir" / "file.txt"
inside.parent.mkdir()
inside.write_text("safe")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(
f'x="{inside}"; cat "$x"',
str(workspace),
workspace_root=str(workspace),
) is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell command-string semantics")
def test_exec_recursively_checks_compact_shell_command_string(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'sh -c "x={link};cat \\"$x\\""'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
def test_exec_malformed_quote_still_extracts_path():
assert "/etc/passwd" in ExecTool._extract_absolute_paths('cat "/etc/passwd')
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX double-slash path semantics")
@pytest.mark.parametrize("path", ["//etc/passwd", "///etc/passwd"])
def test_exec_blocks_double_slash_absolute_paths(tmp_path, path):
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert path in tool._extract_absolute_paths(f"cat {path}")
result = tool._guard_command(
f"cat {path}",
str(workspace),
workspace_root=str(workspace),
)
assert result is not None
assert "path outside working dir" in result
+7 -17
View File
@@ -12,7 +12,6 @@ from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.agent import context as agent_context
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.exec_session import ( from nanobot.agent.tools.exec_session import (
@@ -712,7 +711,7 @@ def test_exec_session_manager_preserves_single_cleanup_error():
asyncio.run(run()) asyncio.run(run())
def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch): def test_agent_loop_shutdown_closes_exec_sessions(tmp_path):
async def run() -> None: async def run() -> None:
manager = ExecSessionManager() manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
@@ -723,14 +722,13 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
sid = _session_id(initial) sid = _session_id(initial)
process = manager._sessions[sid].process process = manager._sessions[sid].process
monkeypatch.setattr(agent_context, "close_mcp", lambda _state: asyncio.sleep(0))
loop = object.__new__(AgentLoop) loop = object.__new__(AgentLoop)
loop._background_tasks = set() loop._background_tasks = set()
loop._exec_session_manager = manager loop._exec_session_manager = manager
loop.subagents = SimpleNamespace(close=AsyncMock()) loop.subagents = SimpleNamespace(close=AsyncMock())
await loop.close_mcp() await loop.aclose()
await loop.close_mcp() await loop.aclose()
assert process.returncode is not None assert process.returncode is not None
assert manager._sessions == {} assert manager._sessions == {}
@@ -739,7 +737,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
asyncio.run(run()) asyncio.run(run())
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch): def test_agent_loop_shutdown_attempts_all_cleanup_after_errors():
async def run() -> None: async def run() -> None:
loop = object.__new__(AgentLoop) loop = object.__new__(AgentLoop)
loop._background_tasks = set() loop._background_tasks = set()
@@ -749,16 +747,12 @@ def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
loop._exec_session_manager = SimpleNamespace( loop._exec_session_manager = SimpleNamespace(
close_all=AsyncMock(side_effect=OSError("exec cleanup failed")), close_all=AsyncMock(side_effect=OSError("exec cleanup failed")),
) )
close_mcp = AsyncMock()
monkeypatch.setattr(agent_context, "close_mcp", close_mcp)
with pytest.raises(BaseExceptionGroup) as exc_info: with pytest.raises(BaseExceptionGroup) as exc_info:
await loop.close_mcp() await loop.aclose()
assert len(exc_info.value.exceptions) == 2 assert len(exc_info.value.exceptions) == 2
loop.subagents.close.assert_awaited_once() loop.subagents.close.assert_awaited_once()
loop._exec_session_manager.close_all.assert_awaited_once() loop._exec_session_manager.close_all.assert_awaited_once()
close_mcp.assert_awaited_once_with(loop)
asyncio.run(run()) asyncio.run(run())
@@ -892,7 +886,7 @@ def test_terminate_by_owner_skips_sessions_without_owner_key(tmp_path):
asyncio.run(run()) asyncio.run(run())
def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch): def test_agent_loop_shutdown_preserves_single_cleanup_error():
async def run() -> None: async def run() -> None:
loop = object.__new__(AgentLoop) loop = object.__new__(AgentLoop)
loop._background_tasks = set() loop._background_tasks = set()
@@ -900,13 +894,9 @@ def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")), close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
) )
loop._exec_session_manager = SimpleNamespace(close_all=AsyncMock()) loop._exec_session_manager = SimpleNamespace(close_all=AsyncMock())
close_mcp = AsyncMock()
monkeypatch.setattr(agent_context, "close_mcp", close_mcp)
with pytest.raises(RuntimeError, match="subagent cleanup failed"): with pytest.raises(RuntimeError, match="subagent cleanup failed"):
await loop.close_mcp() await loop.aclose()
loop._exec_session_manager.close_all.assert_awaited_once() loop._exec_session_manager.close_all.assert_awaited_once()
close_mcp.assert_awaited_once_with(loop)
asyncio.run(run()) asyncio.run(run())
+74 -24
View File
@@ -13,6 +13,7 @@ import pytest
import nanobot.agent.tools.mcp as mcp_mod import nanobot.agent.tools.mcp as mcp_mod
from nanobot.agent.tools.mcp import ( from nanobot.agent.tools.mcp import (
MCPPromptWrapper, MCPPromptWrapper,
MCPProvider,
MCPResourceWrapper, MCPResourceWrapper,
MCPToolWrapper, MCPToolWrapper,
_normalize_windows_stdio_command, _normalize_windows_stdio_command,
@@ -153,7 +154,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connect_missing_servers_propagates_external_cancellation(monkeypatch) -> None: async def test_mcp_provider_connect_propagates_external_cancellation(monkeypatch) -> None:
started = asyncio.Event() started = asyncio.Event()
async def connect_mcp_servers(_servers: dict, _registry: ToolRegistry) -> dict: async def connect_mcp_servers(_servers: dict, _registry: ToolRegistry) -> dict:
@@ -161,24 +162,21 @@ async def test_connect_missing_servers_propagates_external_cancellation(monkeypa
await asyncio.sleep(60) await asyncio.sleep(60)
return {} return {}
class State: provider = MCPProvider(
pass {"test": MCPServerConfig(command="fake")},
ToolRegistry(),
state = State() )
state._mcp_closing = False
state._mcp_servers = {"test": MCPServerConfig(command="fake")}
state._mcp_stacks = {}
state._mcp_connecting = False
monkeypatch.setattr(mcp_mod, "connect_mcp_servers", connect_mcp_servers) monkeypatch.setattr(mcp_mod, "connect_mcp_servers", connect_mcp_servers)
task = asyncio.create_task(mcp_mod.connect_missing_servers(state, ToolRegistry())) task = asyncio.create_task(provider.connect())
await asyncio.wait_for(started.wait(), timeout=1.0) await asyncio.wait_for(started.wait(), timeout=1.0)
task.cancel() task.cancel()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await task await task
assert state._mcp_connecting is False assert provider.connected_server_names == set()
assert provider.runtime_status() == {"test": "failed"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -223,25 +221,20 @@ async def test_saved_oauth_http_403_projects_failed_runtime_without_details(
rejected_streamable_http, rejected_streamable_http,
) )
class State: provider = MCPProvider(
pass {
state = State()
state._mcp_closing = False
state._mcp_servers = {
"xmind": MCPServerConfig( "xmind": MCPServerConfig(
type="streamableHttp", type="streamableHttp",
auth="oauth", auth="oauth",
url="https://app.xmind.com/api/mcp", url="https://app.xmind.com/api/mcp",
) )
} },
state._mcp_stacks = {} ToolRegistry(),
state._mcp_runtime_statuses = {} )
state._mcp_connecting = False
await mcp_mod.connect_missing_servers(state, ToolRegistry()) await provider.connect()
snapshot = mcp_mod.runtime_status(state) snapshot = provider.runtime_status()
assert snapshot == {"xmind": "failed"} assert snapshot == {"xmind": "failed"}
assert "saved-oauth-secret" not in str(snapshot) assert "saved-oauth-secret" not in str(snapshot)
assert "app.xmind.com" not in str(snapshot) assert "app.xmind.com" not in str(snapshot)
@@ -1263,6 +1256,59 @@ async def test_connect_mcp_servers_propagates_external_cancellation(
await asyncio.wait_for(closed.wait(), timeout=1.0) await asyncio.wait_for(closed.wait(), timeout=1.0)
@pytest.mark.asyncio
async def test_connect_mcp_servers_rolls_back_completed_batch_on_cancellation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
slow_started = asyncio.Event()
closed: list[str] = []
sessions = {"fast": _make_fake_session(["demo"])}
class _SelectiveClientSession:
def __init__(self, read: object, _write: object) -> None:
self._session = sessions[str(read)]
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False
@asynccontextmanager
async def _selective_stdio_client(params: object):
command = str(params.command)
try:
if command == "slow":
slow_started.set()
await asyncio.Event().wait()
yield command, object()
finally:
closed.append(command)
monkeypatch.setattr(sys.modules["mcp"], "ClientSession", _SelectiveClientSession)
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _selective_stdio_client)
registry = ToolRegistry()
task = asyncio.create_task(
connect_mcp_servers(
{
"fast": MCPServerConfig(command="fast"),
"slow": MCPServerConfig(command="slow"),
},
registry,
)
)
await asyncio.wait_for(slow_started.wait(), timeout=1.0)
assert registry.tool_names == ["mcp_fast_demo"]
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert registry.tool_names == []
assert sorted(closed) == ["fast", "slow"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout( async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
fake_mcp_runtime: dict[str, object | None], fake_mcp_runtime: dict[str, object | None],
@@ -1900,7 +1946,11 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
assert len(wrapper.name) == 64 assert len(wrapper.name) == 64
assert not wrapper.name.startswith(mcp_mod._tool_prefix(server_name)) assert not wrapper.name.startswith(mcp_mod._tool_prefix(server_name))
mcp_mod._attach_reconnect_handlers(SimpleNamespace(), registry, {server_name}) provider = MCPProvider(
{server_name: MCPServerConfig(command="fake")},
registry,
)
provider._attach_reconnect_handlers({server_name})
assert wrapper._reconnect is not None assert wrapper._reconnect is not None
assert other_wrapper._reconnect is None assert other_wrapper._reconnect is None
+104 -11
View File
@@ -3,6 +3,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import shlex
import subprocess
import sys import sys
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -12,6 +15,12 @@ from nanobot.agent.tools.exec_session import _ExecSession
from nanobot.agent.tools.shell import ExecTool, _reap_pid from nanobot.agent.tools.shell import ExecTool, _reap_pid
def _python_command(code: str) -> str:
if sys.platform == "win32":
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
def test_reap_pid_noops_without_waitpid(): def test_reap_pid_noops_without_waitpid():
"""On platforms (or test stubs) without waitpid, reaping is a no-op.""" """On platforms (or test stubs) without waitpid, reaping is a no-op."""
with patch("nanobot.agent.tools.shell.os") as mock_os: with patch("nanobot.agent.tools.shell.os") as mock_os:
@@ -112,20 +121,37 @@ async def test_execute_timeout_kills_and_reaps():
mock_proc.pid = 1002 mock_proc.pid = 1002
mock_proc.returncode = None mock_proc.returncode = None
mock_proc.communicate.side_effect = asyncio.TimeoutError() mock_proc.communicate.side_effect = asyncio.TimeoutError()
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock(return_value=-9)
with ( with (
patch.object(ExecTool, "_spawn", return_value=mock_proc), patch.object(ExecTool, "_spawn", return_value=mock_proc) as spawn,
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
patch("nanobot.agent.tools.shell._reap_pid") as reap, patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
): ):
tool = ExecTool(timeout=1) tool = ExecTool(timeout=1)
result = await tool.execute(command="sleep 99", timeout=1) result = await tool.execute(command="sleep 99", timeout=1)
assert "timed out" in result.lower() assert "timed out" in result.lower()
mock_proc.kill.assert_called_once() kill_tree.assert_awaited_once_with(mock_proc)
reap.assert_called_with(1002) assert spawn.await_args.kwargs["process_tree"] is True
@pytest.mark.asyncio
async def test_execute_cancellation_kills_process_tree():
mock_proc = AsyncMock()
mock_proc.pid = 1005
mock_proc.returncode = None
mock_proc.communicate.side_effect = asyncio.CancelledError()
with (
patch.object(ExecTool, "_spawn", return_value=mock_proc) as spawn,
patch.object(ExecTool, "_guard_command", return_value=None),
patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
):
with pytest.raises(asyncio.CancelledError):
await ExecTool().execute(command="sleep 99")
kill_tree.assert_awaited_once_with(mock_proc)
assert spawn.await_args.kwargs["process_tree"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -161,21 +187,88 @@ async def test_execute_exception_during_communicate_kills_live_process():
mock_proc.pid = 1004 mock_proc.pid = 1004
mock_proc.returncode = None mock_proc.returncode = None
mock_proc.communicate.side_effect = OSError("pipe broken") mock_proc.communicate.side_effect = OSError("pipe broken")
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock(return_value=-1)
with ( with (
patch.object(ExecTool, "_spawn", return_value=mock_proc), patch.object(ExecTool, "_spawn", return_value=mock_proc),
patch.object(ExecTool, "_guard_command", return_value=None), patch.object(ExecTool, "_guard_command", return_value=None),
patch("nanobot.agent.tools.shell._reap_pid") as reap, patch.object(ExecTool, "_kill_process_tree", new_callable=AsyncMock) as kill_tree,
): ):
tool = ExecTool() tool = ExecTool()
result = await tool.execute(command="broken") result = await tool.execute(command="broken")
assert "Error executing command" in result assert "Error executing command" in result
assert "pipe broken" in result assert "pipe broken" in result
mock_proc.kill.assert_called_once() kill_tree.assert_awaited_once_with(mock_proc)
reap.assert_called_with(1004)
@pytest.mark.asyncio
async def test_kill_process_tree_targets_group_after_root_exits():
process = AsyncMock()
process.pid = 1006
process.returncode = 0
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch("nanobot.agent.tools.shell.os.killpg", create=True) as kill_group,
patch("nanobot.agent.tools.shell.signal.SIGKILL", 9, create=True),
patch("nanobot.agent.tools.shell._reap_pid") as reap,
):
await ExecTool._kill_process_tree(process)
kill_group.assert_called_once_with(1006, 9)
process.kill.assert_not_called()
reap.assert_called_once_with(1006)
@pytest.mark.skipif(sys.platform == "win32", reason="requires Unix process groups")
@pytest.mark.asyncio
async def test_execute_timeout_kills_background_process_tree(tmp_path):
"""A one-shot timeout must stop background descendants before they write."""
marker = tmp_path / "child-survived"
command = f"(sleep 2; touch {shlex.quote(str(marker))}) >/dev/null 2>&1 & sleep 30"
result = await ExecTool(working_dir=str(tmp_path), timeout=1).execute(
command=command,
timeout=1,
)
assert "timed out" in result.lower()
await asyncio.sleep(2.5)
assert not marker.exists()
@pytest.mark.asyncio
async def test_execute_timeout_kills_descendant_after_root_exits(tmp_path):
"""Tree ownership must outlive a root shell that exits before timeout."""
marker = tmp_path / "child-survived-root"
child_code = (
"import pathlib,time; time.sleep(3.5); "
f"pathlib.Path({str(marker)!r}).write_text('alive')"
)
child_payload = base64.b64encode(child_code.encode()).decode()
parent_code = (
"import base64,subprocess,sys; "
f"child=base64.b64decode('{child_payload}').decode(); "
"subprocess.Popen([sys.executable, '-c', child])"
)
spawned = []
original_spawn = ExecTool._spawn
async def capture_spawn(*args, **kwargs):
process = await original_spawn(*args, **kwargs)
spawned.append(process)
return process
with patch.object(ExecTool, "_spawn", side_effect=capture_spawn):
result = await ExecTool(working_dir=str(tmp_path), timeout=2).execute(
command=_python_command(parent_code),
timeout=2,
)
assert "timed out" in result.lower()
assert spawned[0].returncode == 0
await asyncio.sleep(2)
assert not marker.exists()
def _mock_session_process(*, pid: int, returncode: int | None): def _mock_session_process(*, pid: int, returncode: int | None):
+76 -1
View File
@@ -290,10 +290,11 @@ def test_exec_extract_absolute_paths_captures_home_paths() -> None:
def test_exec_extract_absolute_paths_captures_paths_after_equals() -> None: def test_exec_extract_absolute_paths_captures_paths_after_equals() -> None:
cmd = "curl --output=/etc/passwd --config=~/.nanobot/config.json" cmd = "curl --output=/etc/passwd --config=~/.nanobot/config.json --user-home=~root"
paths = ExecTool._extract_absolute_paths(cmd) paths = ExecTool._extract_absolute_paths(cmd)
assert "/etc/passwd" in paths assert "/etc/passwd" in paths
assert "~/.nanobot/config.json" in paths assert "~/.nanobot/config.json" in paths
assert "~root" in paths
def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None: def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None:
@@ -302,6 +303,29 @@ def test_exec_extract_absolute_paths_does_not_capture_query_tilde() -> None:
assert not any(p.startswith("~") for p in paths) assert not any(p.startswith("~") for p in paths)
def test_exec_extract_absolute_paths_captures_bare_and_named_user_home_paths() -> None:
paths = ExecTool._extract_absolute_paths("cd ~ && cat ~root/.bashrc")
assert "~" in paths
assert "~root/.bashrc" in paths
def test_exec_extract_absolute_paths_captures_tilde_after_shell_operators() -> None:
paths = ExecTool._extract_absolute_paths(
"cat <~root/.bashrc;~root/bin/tool|~daemon/bin/tool"
)
assert "~root/.bashrc" in paths
assert paths.count("~root/bin/tool") == 1
assert "~daemon/bin/tool" in paths
def test_exec_extract_absolute_paths_captures_tilde_assignment_components() -> None:
paths = ExecTool._extract_absolute_paths(
"HOME=~ PATH=bin:~root/bin curl --config=~"
)
assert "~" in paths
assert "~root/bin" in paths
def test_exec_extract_absolute_paths_captures_quoted_paths() -> None: def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
cmd = 'cat "/tmp/data.txt" "~/.nanobot/config.json"' cmd = 'cat "/tmp/data.txt" "~/.nanobot/config.json"'
paths = ExecTool._extract_absolute_paths(cmd) paths = ExecTool._extract_absolute_paths(cmd)
@@ -319,6 +343,48 @@ def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
assert "hard policy boundary" in error assert "hard policy boundary" in error
def test_exec_guard_blocks_bare_tilde_cwd_escape(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cd ~ && cat secret.txt", str(tmp_path))
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
def test_exec_guard_blocks_named_user_home_path(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat ~root/.bashrc", str(tmp_path))
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
@pytest.mark.parametrize(
"command",
[
"cat <~root/.bashrc",
"cat ~-/.bashrc",
"cat ~+1/.bashrc",
"cat ~-1/.bashrc",
],
)
def test_exec_guard_blocks_home_paths_with_special_shell_contexts(
tmp_path, command: str
) -> None:
error = ExecTool(restrict_to_workspace=True)._guard_command(command, str(tmp_path))
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
def test_exec_guard_allows_current_directory_tilde(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
assert tool._guard_command("cat ~+/file.txt", str(tmp_path)) is None
def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None: def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True) tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat --config=~/.nanobot/config.json", str(tmp_path)) error = tool._guard_command("cat --config=~/.nanobot/config.json", str(tmp_path))
@@ -328,6 +394,15 @@ def test_exec_guard_blocks_equals_home_path_outside_workspace(tmp_path) -> None:
) )
def test_exec_guard_blocks_equals_named_user_home_path(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat --config=~root/.bashrc", str(tmp_path))
assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True) tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path)) error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
+279
View File
@@ -0,0 +1,279 @@
"""Tests that the Jina Reader path never discloses credential-bearing URLs."""
from __future__ import annotations
import json
import socket
from unittest.mock import patch
import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import (
WebFetchTool,
_redact_url_for_log,
_url_carries_credentials,
)
def _fake_resolve_public(hostname, port, family=0, type_=0):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
class _RecordingJinaClient:
"""Fake httpx.AsyncClient that records every requested URL."""
requested: list[str] = []
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get(self, url, **kwargs):
_RecordingJinaClient.requested.append(url)
class _Response:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"data": {"title": "T", "content": "body", "url": url}}
return _Response()
@pytest.fixture
def jina_client():
_RecordingJinaClient.requested = []
with patch("nanobot.agent.tools.web.httpx.AsyncClient", _RecordingJinaClient):
yield _RecordingJinaClient
@pytest.mark.parametrize(
"url",
[
"https://user:secret@example.com/report",
"https://user@example.com/report",
"https://example.com/download?token=abc123",
"https://example.com/download?access_token=abc123",
"https://example.com/doc?Signature=xyz&Expires=1700000000",
"https://bucket.s3.amazonaws.com/key?X-Amz-Signature=deadbeef",
"https://storage.googleapis.com/o/file?X-Goog-Signature=deadbeef",
"https://example.com/blob?sig=sas-token-material",
"https://maps.example.com/api?key=AIzaFixture",
"https://example.com/callback?code=oauth-code",
"https://example.com/download?API-KEY=secret",
"https://example.com/download?file=report;token=secret",
],
)
def test_credential_urls_are_detected(url: str) -> None:
assert _url_carries_credentials(url) is True
@pytest.mark.parametrize(
"url",
[
"https://example.com/",
"https://example.com/watch?v=abc123",
"https://example.com/search?q=token+design&page=2",
"https://example.com/page#section-3",
],
)
def test_plain_urls_are_not_detected(url: str) -> None:
assert _url_carries_credentials(url) is False
def test_log_label_excludes_every_credential_bearing_component() -> None:
url = "https://user:secret@example.com:8443/private/webhook-token?token=abc#secret"
assert _redact_url_for_log(url) == "https://example.com:8443"
def test_log_label_preserves_ipv6_origin_without_credentials() -> None:
url = "https://user:secret@[2001:db8::1]:8443/private?token=abc"
assert _redact_url_for_log(url) == "https://[2001:db8::1]:8443"
async def test_jina_is_skipped_for_credential_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://example.com/download?token=abc123", max_chars=1000
)
assert result is None
assert jina_client.requested == []
async def test_jina_is_skipped_for_userinfo_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://user:secret@example.com/report", max_chars=1000
)
assert result is None
assert jina_client.requested == []
async def test_jina_skip_log_does_not_contain_url_credentials(
jina_client, monkeypatch
) -> None:
logged: list[tuple[object, ...]] = []
monkeypatch.setattr(web_module.logger, "debug", lambda *args: logged.append(args))
result = await WebFetchTool()._fetch_jina(
"https://user:secret@example.com/private/webhook-token?token=abc",
max_chars=1000,
)
assert result is None
assert jina_client.requested == []
rendered_log_arguments = " ".join(str(item) for call in logged for item in call)
assert "secret" not in rendered_log_arguments
assert "webhook-token" not in rendered_log_arguments
assert "token=abc" not in rendered_log_arguments
async def test_jina_still_used_for_plain_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina("https://example.com/watch?v=abc123", max_chars=1000)
assert result is not None
assert json.loads(result)["extractor"] == "jina"
assert jina_client.requested == [
"https://r.jina.ai/https://example.com/watch?v=abc123"
]
async def test_fragment_is_never_forwarded(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://example.com/page?q=1#access_token=leaked", max_chars=1000
)
assert result is not None
assert jina_client.requested == ["https://r.jina.ai/https://example.com/page?q=1"]
async def test_execute_fetches_credential_urls_locally(monkeypatch) -> None:
"""The tool boundary: a credential URL must use the local extractor and
produce zero requests to the remote reader."""
tool = WebFetchTool()
requested: list[str] = []
class FakeStreamResponse:
status_code = 200
headers = {"content-type": "text/html"}
url = "https://example.com/download"
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeResponse:
status_code = 200
url = "https://example.com/download"
text = "<html><head><title>T</title></head><body><p>ok</p></body></html>"
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None, **kwargs):
requested.append(str(url))
return FakeStreamResponse()
async def get(self, url, headers=None, **kwargs):
requested.append(str(url))
return FakeResponse()
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "ok")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool.execute(url="https://example.com/download?token=abc123")
data = json.loads(result)
assert data["extractor"] == "readability"
assert all("r.jina.ai" not in url for url in requested)
async def test_execute_does_not_send_redirected_credential_url_to_jina(monkeypatch) -> None:
"""A plain short URL that redirects through a signed URL must stay local."""
tool = WebFetchTool()
requested: list[str] = []
short_url = "https://example.com/short"
signed_url = "https://cdn.example.com/file?token=secret"
class FakeStreamResponse:
def __init__(self, url: str):
self.url = url
self.status_code = 302 if url == short_url else 200
self.headers = (
{"location": signed_url}
if url == short_url
else {"content-type": "text/html"}
)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeResponse:
status_code = 200
url = signed_url
text = "<html><head><title>T</title></head><body><p>ok</p></body></html>"
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None, **kwargs):
requested.append(str(url))
return FakeStreamResponse(str(url))
async def get(self, url, headers=None, **kwargs):
requested.append(str(url))
return FakeResponse()
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "ok")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool.execute(url=short_url)
data = json.loads(result)
assert data["extractor"] == "readability"
assert signed_url in requested
assert all("r.jina.ai" not in url for url in requested)
+99 -1
View File
@@ -1,5 +1,9 @@
import json import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import nanobot.webui.sidebar_state as sidebar_state
from nanobot.webui.sidebar_state import ( from nanobot.webui.sidebar_state import (
default_webui_sidebar_state, default_webui_sidebar_state,
read_webui_sidebar_state, read_webui_sidebar_state,
@@ -17,7 +21,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json" assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None: def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
path = webui_sidebar_state_path() path = webui_sidebar_state_path()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -31,6 +35,23 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
"project_name_overrides": {"/repo": " Core ", "bad": ""}, "project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]}, "tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1}, "collapsed_groups": {"Earlier": 1},
"workbench": {
"version": 1,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"title": " Research ",
"paneKeys": ["websocket:a", "websocket:b", "websocket:a"],
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
"layout": "invalid-layout",
"splitRatios": [0.4, 2, "bad", float("nan")],
},
"tab:websocket:b": {
"paneKeys": ["websocket:b", "websocket:c"],
"layout": "bsp",
},
},
},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"}, "view": {"density": "tiny", "show_archived": True, "sort": "nope"},
} }
), ),
@@ -47,6 +68,19 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]} assert state["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True} assert state["collapsed_groups"] == {"Earlier": True}
assert state["workbench"] == {
"version": 1,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"title": "Research",
"paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"layout": "columns",
"splitRatios": [0.4, 0.95],
},
},
}
assert state["view"] == { assert state["view"] == {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
@@ -80,3 +114,67 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
assert state["view"]["sort"] == "manual" assert state["view"]["sort"] == "manual"
assert webui_sidebar_state_path().is_file() assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
tabs = {
f"tab:websocket:{index}": {
"explicit": False,
"paneKeys": [f"websocket:{index}"],
"layoutPaneKeys": [f"websocket:{index}"],
"layout": "columns",
"splitRatios": [],
}
for index in range(2_000)
}
state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}})
assert state["workbench"] == {"version": 1, "tabs": {}}
assert webui_sidebar_state_path().stat().st_size < 2_048
def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
state = write_webui_sidebar_state(
{
"workbench": {
"version": 2,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"paneKeys": ["websocket:a"],
}
},
}
}
)
assert state["workbench"] == {"version": 1, "tabs": {}}
def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None:
counter_lock = threading.Lock()
active_writes = 0
peak_writes = 0
def fake_write(raw: dict[str, object]) -> dict[str, object]:
nonlocal active_writes, peak_writes
with counter_lock:
active_writes += 1
peak_writes = max(peak_writes, active_writes)
time.sleep(0.01)
with counter_lock:
active_writes -= 1
return raw
monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write)
payloads = [{"write": index} for index in range(12)]
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads))
assert results == payloads
assert peak_writes == 1
+41 -1
View File
@@ -10,7 +10,7 @@ from mcp.shared.auth import OAuthToken
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config from nanobot.config.loader import load_config, save_config
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import (
McpPresetError, McpPresetError,
custom_mcp_action, custom_mcp_action,
@@ -454,6 +454,46 @@ def test_test_mcp_preset_connects_and_reports_tools(
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"] assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
def test_test_mcp_preset_inspects_tools_outside_the_enabled_allowlist(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
config = load_config()
config.tools.mcp_servers["playwright"].enabled_tools = [
"mcp_playwright_browser_navigate",
]
save_config(config)
class FakeStack:
async def aclose(self) -> None:
return None
async def fake_connect(servers, registry):
assert servers["playwright"].enabled_tools == ["*"]
class FakeTool:
def __init__(self, name: str) -> None:
self.name = name
def to_schema(self):
return {"name": self.name, "description": "", "parameters": {}}
for index in range(20):
registry.register(FakeTool(f"mcp_playwright_tool_{index:02d}"))
return {"playwright": FakeStack()}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
assert payload["last_action"]["tool_count"] == 20
assert len(payload["last_action"]["tool_names"]) == 20
row = next(item for item in payload["presets"] if item["name"] == "playwright")
assert row["enabled_tools"] == ["mcp_playwright_browser_navigate"]
def test_test_mcp_preset_scrubs_connection_errors( def test_test_mcp_preset_scrubs_connection_errors(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+273 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import json
import os import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -17,6 +18,13 @@ from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
@pytest.fixture(autouse=True)
def _isolate_webui_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
def test_webui_session_list_reuses_valid_index_without_scanning_files( def test_webui_session_list_reuses_valid_index_without_scanning_files(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,
@@ -208,6 +216,269 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
assert list_webui_sessions(manager) == [] assert list_webui_sessions(manager) == []
def test_webui_session_list_recovers_transcript_without_canonical_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:restored"
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"restored","text":"original question",'
'"created_at_ms":1785502800000}\n'
'{"event":"message","chat_id":"restored","text":"original answer",'
'"created_at_ms":1785502801000}\n'
'{"event":"turn_end","chat_id":"restored","created_at_ms":1785502802000}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
[row] = list_webui_sessions(manager)
assert row["key"] == key
assert row["preview"] == "original question"
assert row["created_at"] == datetime.fromtimestamp(1785502800).isoformat()
assert not manager._get_session_path(key).exists()
assert manager.list_sessions() == []
reloaded = SessionManager(tmp_path / "workspace")
assert [row["key"] for row in list_webui_sessions(reloaded)] == [key]
def test_webui_session_list_recovers_colon_chat_id_from_transcript(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:scope:child"
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"scope:child","text":"scoped history"}\n',
encoding="utf-8",
)
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
assert row["key"] == key
assert row["preview"] == "scoped history"
def test_webui_session_list_normalizes_transcript_preview(tmp_path: Path) -> None:
key = "websocket:long-preview"
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
json.dumps(
{
"event": "user",
"chat_id": "long-preview",
"text": "first\n\n" + "word " * 100,
}
)
+ "\n",
encoding="utf-8",
)
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
assert row["preview"].startswith("first word")
assert "\n" not in row["preview"]
assert row["preview"].endswith("")
def test_webui_session_list_tolerates_invalid_transcript_timestamp(
tmp_path: Path,
) -> None:
key = "websocket:bad-time"
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"bad-time","text":"still visible",'
'"created_at_ms":1e100}\n',
encoding="utf-8",
)
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
assert row["preview"] == "still visible"
datetime.fromisoformat(row["created_at"])
def test_webui_session_list_ignores_invalid_transcript_chat_id(tmp_path: Path) -> None:
transcript = tmp_path / "webui" / "websocket_.._outside.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"../outside","text":"do not expose"}\n',
encoding="utf-8",
)
assert list_webui_sessions(SessionManager(tmp_path / "workspace")) == []
def test_webui_session_list_recovers_segment_only_transcript(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:segmented"
segments = webui_dir / f"{SessionManager.safe_key(key)}.segments"
segments.mkdir()
(segments / "000001.jsonl").write_text(
'{"event":"user","chat_id":"segmented","text":"older segment"}\n'
'{"event":"turn_end","chat_id":"segmented"}\n',
encoding="utf-8",
)
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
assert row["key"] == key
assert row["preview"] == "older segment"
def test_webui_session_list_prefers_canonical_metadata_without_duplicate(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:canonical"
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
'{"event":"user","chat_id":"canonical","text":"display copy"}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
session = manager.get_or_create(key)
session.metadata["title"] = "Canonical title"
session.add_message("user", "canonical preview")
manager.save(session)
rows = list_webui_sessions(manager)
assert len(rows) == 1
assert rows[0]["key"] == key
assert rows[0]["preview"] == "canonical preview"
def test_webui_session_list_reuses_unchanged_transcript_index(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:cached-transcript"
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
'{"event":"user","chat_id":"cached-transcript","text":"cached"}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
assert list_webui_sessions(manager)[0]["preview"] == "cached"
def fail_scan(*args, **kwargs):
raise AssertionError("unchanged transcript should reuse its index row")
monkeypatch.setattr(session_list_index, "_scan_transcript_row", fail_scan)
assert list_webui_sessions(manager)[0]["preview"] == "cached"
def test_webui_session_list_does_not_cache_changed_transcript_with_old_signature(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
key = "websocket:transcript-race"
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"transcript-race","text":"initial"}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
assert list_webui_sessions(manager)[0]["preview"] == "initial"
transcript.write_text(
'{"event":"user","chat_id":"transcript-race","text":"first scan"}\n',
encoding="utf-8",
)
original_open = open
changed = False
class RacingReader(io.StringIO):
def __next__(self) -> str:
nonlocal changed
if not changed:
changed = True
transcript.write_text(
'{"event":"user","chat_id":"transcript-race","text":"second scan"}\n',
encoding="utf-8",
)
return super().__next__()
def racing_open(path, *args, **kwargs):
if Path(path) == transcript:
with original_open(path, *args, **kwargs) as source:
return RacingReader(source.read())
return original_open(path, *args, **kwargs)
monkeypatch.setattr(session_list_index, "open", racing_open, raising=False)
first = list_webui_sessions(manager)
second = list_webui_sessions(manager)
assert first[0]["preview"] == "first scan"
assert second[0]["preview"] == "second scan"
def test_webui_session_list_drops_deleted_transcript_index_row(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
key = "websocket:deleted-transcript"
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
transcript.write_text(
'{"event":"user","chat_id":"deleted-transcript","text":"delete me"}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
assert list_webui_sessions(manager)[0]["key"] == key
transcript.unlink()
assert list_webui_sessions(manager) == []
def test_webui_session_list_keeps_runtime_instances_isolated(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first_dir = tmp_path / "instance-a" / "webui"
second_dir = tmp_path / "instance-b" / "webui"
first_dir.mkdir(parents=True)
second_dir.mkdir(parents=True)
(first_dir / "websocket_first.jsonl").write_text(
'{"event":"user","chat_id":"first","text":"first instance"}\n',
encoding="utf-8",
)
(second_dir / "websocket_second.jsonl").write_text(
'{"event":"user","chat_id":"second","text":"second instance"}\n',
encoding="utf-8",
)
manager = SessionManager(tmp_path / "workspace")
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: first_dir)
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:first"]
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: second_dir)
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:second"]
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None: def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl" legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
@@ -269,7 +540,7 @@ def test_webui_session_list_uses_webui_transcript_activity_for_sort(
monkeypatch, monkeypatch,
) -> None: ) -> None:
webui_dir = tmp_path / "webui" webui_dir = tmp_path / "webui"
webui_dir.mkdir() webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
@@ -311,7 +582,7 @@ def test_webui_session_list_rescans_when_transcript_changes(
monkeypatch, monkeypatch,
) -> None: ) -> None:
webui_dir = tmp_path / "webui" webui_dir = tmp_path / "webui"
webui_dir.mkdir() webui_dir.mkdir(exist_ok=True)
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
@@ -416,4 +687,3 @@ def test_session_manager_list_sessions_fallback_time_when_missing(tmp_path: Path
assert sessions[0]["updated_at"] is not None assert sessions[0]["updated_at"] is not None
datetime.fromisoformat(sessions[0]["created_at"]) datetime.fromisoformat(sessions[0]["created_at"])
datetime.fromisoformat(sessions[0]["updated_at"]) datetime.fromisoformat(sessions[0]["updated_at"])
+31 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from collections.abc import Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock from unittest.mock import ANY, AsyncMock, MagicMock
@@ -22,6 +23,7 @@ def _router(
authorized: bool = True, authorized: bool = True,
config_path: Path | None = None, config_path: Path | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, object]]] | None = None,
) -> WebUISettingsRouter: ) -> WebUISettingsRouter:
return WebUISettingsRouter( return WebUISettingsRouter(
settings=WebUISettingsServices.create(config_path or get_config_path()), settings=WebUISettingsServices.create(config_path or get_config_path()),
@@ -37,6 +39,7 @@ def _router(
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities={}, runtime_capabilities={},
mcp_runtime_status=mcp_runtime_status, mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload,
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback", mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
) )
@@ -89,6 +92,33 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N
assert snapshot_calls == 1 assert snapshot_calls == 1
@pytest.mark.asyncio
async def test_mcp_reload_callback_is_bounded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
async def reload_mcp() -> dict[str, object]:
started.set()
await asyncio.Event().wait()
return {"ok": True}
monkeypatch.setattr(
"nanobot.webui.settings_routes._MCP_RELOAD_TIMEOUT_SECONDS",
0.01,
)
router = _router(mcp_reload=reload_mcp)
result = await router._reload_mcp_runtime()
assert started.is_set()
assert result == {
"ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
config = SimpleNamespace( config = SimpleNamespace(
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no" content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/> />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta <meta
+92 -33
View File
@@ -1,5 +1,23 @@
const CACHE_NAME = "nanobot-static-v1"; const CACHE_PREFIX = "nanobot-static-";
const PRECACHE = ["/", "/manifest.json"]; const CACHE_NAME = `${CACHE_PREFIX}v2`;
const ASSET_MANIFEST_PATH = "/asset-manifest.json";
const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH];
const NETWORK_FIRST_STATIC_PATHS = new Set([
"/",
"/manifest.json",
ASSET_MANIFEST_PATH,
"/brand/nanobot_apple_touch.png",
"/brand/nanobot_favicon_32.png",
"/brand/nanobot_icon_192.png",
"/brand/nanobot_icon_512.png",
"/brand/nanobot_icon_maskable.png",
"/brand/nanobot_mark.svg",
]);
function responseMayBeCached(response) {
const cacheControl = response.headers?.get("Cache-Control") ?? "";
return response.ok && !/(?:^|,)\s*(?:private|no-cache|no-store)\b/i.test(cacheControl);
}
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
@@ -20,6 +38,42 @@ function referencedAssetPaths(html) {
return refs; return refs;
} }
// Vite's build manifest contains every emitted entry, static dependency, and
// lazy chunk. The HTML alone only references the entry chunk, so pruning from
// its tags can delete a current build's not-yet-requested dynamic imports.
async function manifestedAssetPaths(cache) {
const response = await cache.match(ASSET_MANIFEST_PATH);
if (!response) return new Set();
try {
const manifest = await response.json();
const refs = new Set();
for (const entry of Object.values(manifest)) {
if (!entry || typeof entry !== "object") continue;
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
if (typeof file !== "string") continue;
const url = new URL(file, self.location.origin);
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
}
}
return refs;
} catch {
return new Set();
}
}
async function refreshAssetManifest(cache) {
const response = await fetch(ASSET_MANIFEST_PATH, { cache: "no-store" });
if (!response.ok) return false;
try {
const manifest = await response.clone().json();
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return false;
} catch {
return false;
}
await cache.put(ASSET_MANIFEST_PATH, response);
return true;
}
// Drop cached entries that the current index.html no longer references. // Drop cached entries that the current index.html no longer references.
// CACHE_NAME is stable across deployments, so without this, hashed assets from // CACHE_NAME is stable across deployments, so without this, hashed assets from
// previous builds would pile up in the same cache forever. The cached // previous builds would pile up in the same cache forever. The cached
@@ -31,11 +85,16 @@ async function pruneStaleEntries() {
const cachedIndex = await cache.match("/"); const cachedIndex = await cache.match("/");
if (!cachedIndex) return; if (!cachedIndex) return;
const refs = referencedAssetPaths(await cachedIndex.text()); const refs = referencedAssetPaths(await cachedIndex.text());
for (const path of await manifestedAssetPaths(cache)) refs.add(path);
const keys = await cache.keys(); const keys = await cache.keys();
await Promise.all( await Promise.all(
keys.map(async (request) => { keys.map(async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === "/" || url.pathname === "/manifest.json") return; if (
url.pathname === "/"
|| url.pathname === "/manifest.json"
|| url.pathname === ASSET_MANIFEST_PATH
) return;
if (refs.has(url.pathname + url.search)) return; if (refs.has(url.pathname + url.search)) return;
await cache.delete(request); await cache.delete(request);
}) })
@@ -49,7 +108,7 @@ self.addEventListener("activate", (event) => {
.then((keys) => .then((keys) =>
Promise.all( Promise.all(
keys keys
.filter((k) => k !== CACHE_NAME) .filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
.map((k) => caches.delete(k)) .map((k) => caches.delete(k))
) )
) )
@@ -65,28 +124,13 @@ self.addEventListener("fetch", (event) => {
// (never reconstructed), so their credentials mode is preserved and gateway // (never reconstructed), so their credentials mode is preserved and gateway
// auth cookies flow through on every path we touch. WebSocket upgrades are // auth cookies flow through on every path we touch. WebSocket upgrades are
// never dispatched to a service worker's fetch handler, so the WS endpoint // never dispatched to a service worker's fetch handler, so the WS endpoint
// cannot be cached; the /__nanobot exclusion below still protects its HTTP // cannot be cached. Unknown HTTP endpoints are passed through below.
// polling/socket bootstrap endpoints.
if (request.method !== "GET") return; if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return; if (new URL(request.url).origin !== self.location.origin) return;
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
// particular /webui/bootstrap issues fresh gateway credentials on every page
// load and must never be cached or replayed offline. The /auth prefix covers
// the default token endpoint; custom token_issue_path values should be kept
// under one of these prefixes.
if (
path.startsWith("/api") ||
path.startsWith("/auth") ||
path.startsWith("/__nanobot") ||
path.startsWith("/webui")
) {
return;
}
// Static assets: cache-first. Only files under /assets/ carry content hashes // Static assets: cache-first. Only files under /assets/ carry content hashes
// (the gateway serves them immutable); brand icons, the favicon and other // (the gateway serves them immutable); brand icons, the favicon and other
// un-hashed files can change between releases and stay on the network-first // un-hashed files can change between releases and stay on the network-first
@@ -96,7 +140,7 @@ self.addEventListener("fetch", (event) => {
caches.match(request).then((cached) => { caches.match(request).then((cached) => {
if (cached) return cached; if (cached) return cached;
return fetch(request).then((response) => { return fetch(request).then((response) => {
if (response.ok) { if (responseMayBeCached(response)) {
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone)); caches.open(CACHE_NAME).then((c) => c.put(request, clone));
} }
@@ -107,20 +151,35 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Everything else: network-first (index.html, manifest, brand assets, etc.) // Cache only explicit public files and browser navigations. Dynamic routes
event.respondWith( // are deliberately passed through because token_issue_path and extension
fetch(request) // endpoints are configurable and cannot be safely identified by prefixes.
.then((response) => { const isNavigation = request.mode === "navigate";
if (response.ok) { if (!isNavigation && !NETWORK_FIRST_STATIC_PATHS.has(path)) return;
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone)); // App shell and public files: network-first with an offline fallback.
// The shell just changed; prune entries the new index.html no longer const networkResponse = fetch(request);
// references so hashed assets from old builds do not accumulate even event.waitUntil(
// when sw.js itself is unchanged between deployments. networkResponse
if (path === "/") pruneStaleEntries(); .then(async (response) => {
if (!responseMayBeCached(response)) return;
// Clone before the first await. The original response is also handed
// to respondWith(), which may lock its body as soon as this callback
// yields to the event loop.
const cachedResponse = response.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put(isNavigation ? "/" : request, cachedResponse);
// Refresh the complete build graph before pruning. A deployment can
// change index.html without changing sw.js, so this cannot rely only
// on the manifest cached when the worker was installed.
if (isNavigation || path === "/") {
if (await refreshAssetManifest(cache)) await pruneStaleEntries();
} }
return response;
}) })
.catch(() => undefined)
);
event.respondWith(
networkResponse
.catch(() => { .catch(() => {
// Offline: serve the app shell for navigations (deep links resolve // Offline: serve the app shell for navigations (deep links resolve
// client-side), the last cached copy for everything else. // client-side), the last cached copy for everything else.
+539 -54
View File
@@ -12,8 +12,27 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import type { SidebarDeleteItem } from "@/components/ChatList";
import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell"; import { ThreadShell } from "@/components/thread/ThreadShell";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import {
MAX_WORKBENCH_PANES,
addWorkbenchPane,
attachWorkbenchPane,
createWorkbenchTab,
detachWorkbenchPane,
dissolveWorkbenchTab,
orderWorkbenchTabs,
reconcileWorkbench,
renameWorkbenchTab,
setWorkbenchLayout,
setWorkbenchPaneLayoutOrder,
setWorkbenchSplitRatios,
workbenchTab,
workbenchTabForPane,
type WorkbenchState,
} from "@/components/workbench/workbench-model";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface"; import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -23,6 +42,7 @@ import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills"; import { useSkills } from "@/hooks/useSkills";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { logoFallbackUrls } from "@/lib/provider-brand"; import { logoFallbackUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -35,7 +55,7 @@ import {
loadSavedSecret, loadSavedSecret,
saveSecret, saveSecret,
} from "@/lib/bootstrap"; } from "@/lib/bootstrap";
import { displayTitle } from "@/lib/chat-groups"; import { displayTitle, sortSessions } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider"; import { ClientProvider, useClient } from "@/providers/ClientProvider";
@@ -1017,7 +1037,11 @@ function Shell({
deleteChat, deleteChat,
getSessionAutomations, getSessionAutomations,
} = useSessions(); } = useSessions();
const { state: sidebarState, update: updateSidebarState } = const {
state: sidebarState,
loading: sidebarStateLoading,
update: updateSidebarState,
} =
useSidebarState(sessions, !loading); useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
@@ -1034,15 +1058,31 @@ function Shell({
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const mobileWorkbench = useMediaQuery("(max-width: 767px)");
const workbenchState = sidebarState.workbench;
const updateWorkbenchState = useCallback((
updater: (current: WorkbenchState) => WorkbenchState,
) => {
void updateSidebarState((current) => {
const next = updater(current.workbench);
return next === current.workbench ? current : { ...current, workbench: next };
});
}, [updateSidebarState]);
const lastActivePaneByTabRef = useRef(new Map<string, string>());
const [creatingPane, setCreatingPane] = useState(false);
const topicSessions = sessions;
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
key: string; items: SidebarDeleteItem[];
label: string;
automations?: SessionAutomationJob[]; automations?: SessionAutomationJob[];
} | null>(null); } | null>(null);
const [pendingRename, setPendingRename] = useState<{ const [pendingRename, setPendingRename] = useState<{
key: string; key: string;
label: string; label: string;
} | null>(null); } | null>(null);
const [pendingTabRename, setPendingTabRename] = useState<{
key: string;
label: string;
} | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{ const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string; key: string;
label: string; label: string;
@@ -1220,9 +1260,21 @@ function Shell({
if (temporarySessions[activeKey]) return temporarySessions[activeKey]; if (temporarySessions[activeKey]) return temporarySessions[activeKey];
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, temporarySessions]); }, [sessions, activeKey, temporarySessions]);
const activeTabMatch = useMemo(() => (
activeKey && !temporarySessions[activeKey]
? workbenchTabForPane(workbenchState, activeKey)
: null
), [activeKey, temporarySessions, workbenchState]);
const activeTabKey = activeTabMatch?.tabKey ?? null;
const activeTabState = activeTabMatch?.tab ?? null;
const activePaneSession = activeSession;
useEffect(() => {
if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return;
lastActivePaneByTabRef.current.set(activeTabKey, activeKey);
}, [activeKey, activeTabKey, activeTabState]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activePaneSession?.chatId ?? null;
useEffect(() => { useEffect(() => {
activeChatIdRef.current = activeChatId; activeChatIdRef.current = activeChatId;
if (!activeChatId) return; if (!activeChatId) return;
@@ -1242,13 +1294,13 @@ function Shell({
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
} }
if (activeSession?.workspaceScope) { if (activePaneSession?.workspaceScope) {
return activeSession.workspaceScope; return activePaneSession.workspaceScope;
} }
return draftWorkspaceScope ?? workspaces?.default_scope ?? null; return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [ }, [
activeChatId, activeChatId,
activeSession?.workspaceScope, activePaneSession?.workspaceScope,
draftWorkspaceScope, draftWorkspaceScope,
temporaryChatRequested, temporaryChatRequested,
workspaceOverrides, workspaceOverrides,
@@ -1284,6 +1336,19 @@ function Shell({
}); });
}, [loading, sessions]); }, [loading, sessions]);
useEffect(() => {
if (loading || sidebarStateLoading) return;
const validKeys = new Set(sessions.map((session) => session.key));
updateWorkbenchState((current) => {
return reconcileWorkbench(current, validKeys);
});
}, [
loading,
sidebarStateLoading,
sessions,
updateWorkbenchState,
]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
const pendingCreatedKey = pendingCreatedSessionKeyRef.current; const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
@@ -1715,6 +1780,18 @@ function Shell({
[pendingRename, updateSidebarState], [pendingRename, updateSidebarState],
); );
const onRequestRenameTab = useCallback((key: string, label: string) => {
setPendingTabRename({ key, label });
}, []);
const onConfirmTabRename = useCallback((title: string) => {
if (!pendingTabRename) return;
updateWorkbenchState((current) => (
renameWorkbenchTab(current, pendingTabRename.key, title)
));
setPendingTabRename(null);
}, [pendingTabRename, updateWorkbenchState]);
const onToggleGroup = useCallback( const onToggleGroup = useCallback(
(groupId: string) => { (groupId: string) => {
void updateSidebarState((current) => { void updateSidebarState((current) => {
@@ -1788,7 +1865,7 @@ function Shell({
}); });
if (activeKey === key && !sidebarState.archived_keys.includes(key)) { if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]); const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.key)); const next = topicSessions.find((session) => !archived.has(session.key));
navigate({ navigate({
view: "chat", view: "chat",
activeKey: next?.key ?? null, activeKey: next?.key ?? null,
@@ -1796,18 +1873,7 @@ function Shell({
}); });
} }
}, },
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], [activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
);
const onReorderSessions = useCallback(
(sessionOrder: string[]) => {
void updateSidebarState((current) => ({
...current,
session_order: sessionOrder,
view: { ...current.view, sort: "manual" },
}));
},
[updateSidebarState],
); );
const onToggleArchived = useCallback(() => { const onToggleArchived = useCallback(() => {
@@ -1825,6 +1891,57 @@ function Shell({
setSessionSearchOpen(true); setSessionSearchOpen(true);
}, []); }, []);
const onAddPane = useCallback(async () => {
const tabKey = activeTabKey;
if (
!tabKey
|| !activeKey
|| !activeSession
|| creatingPane
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|| temporarySessionsRef.current[activeKey]
) return;
setMobileSidebarOpen(false);
setSessionSearchOpen(false);
setCreatingPane(true);
try {
const scope = activeWorkspaceScope;
const chatId = await createChat(scope);
const paneKey = `websocket:${chatId}`;
pendingCreatedSessionKeyRef.current = paneKey;
updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey));
navigate({
view: "chat",
activeKey: paneKey,
settingsSection: "overview",
});
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
} catch (error) {
console.error("Failed to create pane", error);
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
}
} finally {
setCreatingPane(false);
}
}, [
activeKey,
activeSession,
activeTabKey,
activeTabState,
activeWorkspaceScope,
createChat,
creatingPane,
navigate,
t,
updateWorkbenchState,
]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
@@ -1902,15 +2019,15 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
const nextKey = (() => { const nextKey = (() => {
if (!activeKey) return null; if (!activeKey) return null;
if (sessions.some((session) => session.key === activeKey)) return activeKey; if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null; return topicSessions[0]?.key ?? null;
})(); })();
navigate({ navigate({
view: "chat", view: "chat",
activeKey: nextKey, activeKey: nextKey,
settingsSection: "overview", settingsSection: "overview",
}); });
}, [activeKey, navigate, sessions]); }, [activeKey, navigate, topicSessions]);
const onRestart = useCallback(() => { const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId; const chatId = activeSession?.chatId ?? client.defaultChatId;
@@ -2017,31 +2134,43 @@ function Shell({
}, [client, t]); }, [client, t]);
const onTurnEnd = useDeferredTitleRefresh( const onTurnEnd = useDeferredTitleRefresh(
temporaryChatActive ? null : activeSession, temporaryChatActive ? null : activePaneSession,
refresh, refresh,
); );
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
const key = pendingDelete.key; const items = pendingDelete.items;
const deletingKeys = new Set(items.map((item) => item.key));
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0; const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey === key; const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
const currentIndex = sessions.findIndex((s) => s.key === key); const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
const fallbackKey = deletingActive const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) ? (
topicSessions.slice(currentIndex + 1).find((session) => (
!deletingKeys.has(session.key)
))?.key
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
!deletingKeys.has(session.key)
))?.key
?? null
)
: activeKey; : activeKey;
try { try {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const result = await deleteChat( const result = await deleteChat(
key, item.key,
hasAutomations ? { deleteAutomations: true } : undefined, hasAutomations ? { deleteAutomations: true } : undefined,
); );
if (result.blocked_by_automations) { if (result.blocked_by_automations) {
setPendingDelete({ setPendingDelete({
...pendingDelete, items: items.slice(index),
automations: result.automations ?? [], automations: result.automations ?? [],
}); });
return; return;
} }
}
setPendingDelete(null); setPendingDelete(null);
if (deletingActive) { if (deletingActive) {
navigate({ navigate({
@@ -2053,18 +2182,24 @@ function Shell({
} catch (e) { } catch (e) {
console.error("Failed to delete session", e); console.error("Failed to delete session", e);
} }
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]); }, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
const onRequestDelete = useCallback(async (key: string, label: string) => { const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
let automations: SessionAutomationJob[] = []; const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
try { if (uniqueItems.length === 0) return;
automations = await getSessionAutomations(key); const automationResults = await Promise.allSettled(
} catch { uniqueItems.map((item) => getSessionAutomations(item.key)),
// Delete remains protected by the backend block; prefetch only improves the first prompt. );
} const automations = automationResults.flatMap((result) => (
setPendingDelete({ key, label, automations }); result.status === "fulfilled" ? result.value : []
));
setPendingDelete({ items: uniqueItems, automations });
}, [getSessionAutomations]); }, [getSessionAutomations]);
const onRequestDelete = useCallback((key: string, label: string) => {
void onRequestDeleteMany([{ key, label }]);
}, [onRequestDeleteMany]);
const visiblePairingRequests = useMemo( const visiblePairingRequests = useMemo(
() => { () => {
const now = Date.now(); const now = Date.now();
@@ -2109,13 +2244,218 @@ function Shell({
}); });
}, []); }, []);
const titleForSession = useCallback((session: ChatSummary) => (
sidebarState.title_overrides[session.key]
|| session.title
|| deriveTitle(session.preview, t("chat.newChat"))
), [sidebarState.title_overrides, t]);
const automaticSidebarSort = sidebarState.view.sort === "manual"
? "updated_desc"
: sidebarState.view.sort;
const orderedWorkbenchTabs = useMemo(() => {
const orderedSessions = sortSessions(
sessions,
automaticSidebarSort,
sidebarState.title_overrides,
sidebarState.session_order,
);
const updatedAtByKey = new Map(sessions.map((session) => [
session.key,
session.updatedAt ?? session.createdAt,
]));
return orderWorkbenchTabs(
workbenchState,
orderedSessions.map((session) => session.key),
updatedAtByKey,
);
}, [
automaticSidebarSort,
sessions,
sidebarState.session_order,
sidebarState.title_overrides,
workbenchState,
]);
const orderedWorkbenchTabsByKey = useMemo(
() => new Map(orderedWorkbenchTabs.map((tab) => [tab.tabKey, tab])),
[orderedWorkbenchTabs],
);
const sidebarTabPresentations = useMemo(() => {
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
return orderedWorkbenchTabs.flatMap((tab) => {
const anchorKey = tab.tab.paneKeys.find((key) => sessionsByKey.has(key))
?? tab.paneKeys[0];
const anchor = sessionsByKey.get(anchorKey);
if (!anchor) return [];
const title = tab.tab.title ?? titleForSession(anchor);
const visible = tab.tab.explicit || tab.paneKeys.length > 1;
const rowKey = visible ? tab.tabKey : tab.paneKeys[0];
return [{
orderedTab: tab,
rowKey,
title,
session: visible
? {
...anchor,
key: tab.tabKey,
chatId: `workbench-tab:${tab.tabKey}`,
title,
preview: "",
updatedAt: tab.updatedAt,
}
: anchor,
}];
});
}, [orderedWorkbenchTabs, sessions, titleForSession]);
const sidebarTopicSessions = useMemo(
() => sidebarTabPresentations.map((presentation) => presentation.session),
[sidebarTabPresentations],
);
const headerTitle = temporaryChatActive const headerTitle = temporaryChatActive
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title")) ? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
: activeSession : activeSession
? sidebarState.title_overrides[activeSession.key] || ? titleForSession(activeSession)
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand"); : t("app.brand");
const workbenchPaneSessions = useMemo(() => {
if (!activeTabState) return [];
const byKey = new Map(sessions.map((session) => [session.key, session]));
const sortedPaneKeys = activeTabKey
? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys
: activeTabState.paneKeys;
const paneKeys = [
...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)),
...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)),
];
return paneKeys
.map((key) => byKey.get(key))
.filter((session): session is ChatSummary => session !== undefined);
}, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
const paneChromeEnabled = Boolean(
activeKey && activeSession && !temporaryChatActive && activeTabState,
);
const activeTabVisible = Boolean(
activeTabState
&& (activeTabState.explicit || activeTabState.paneKeys.length > 1),
);
const renderedWorkbenchPanes = useMemo(() => {
if (paneChromeEnabled) {
return workbenchPaneSessions.map((session) => ({
key: session.key,
reactKey: session.key === activeTabState?.paneKeys[0]
? "tab-root"
: `pane:${session.key}`,
title: titleForSession(session),
}));
}
return [{
key: activeKey ?? "new-topic",
reactKey: "tab-root",
title: headerTitle,
}];
}, [
activeKey,
activeTabState?.paneKeys,
headerTitle,
paneChromeEnabled,
titleForSession,
workbenchPaneSessions,
]);
const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key;
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
? activeTabState.layout
: "columns";
const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState
? activeTabState.splitRatios
: [];
const sidebarPaneGroups = useMemo(() => {
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
const orderedTab = presentation.orderedTab;
const panes = orderedTab.paneKeys
.map((key) => sessionsByKey.get(key))
.filter((session): session is ChatSummary => session !== undefined)
.map((session) => ({
key: session.key,
chatId: session.chatId,
title: titleForSession(session),
}));
return [presentation.rowKey, {
tabKey: orderedTab.tabKey,
title: presentation.title,
activePaneKey: activeKey && orderedTab.paneKeys.includes(activeKey)
? activeKey
: orderedTab.paneKeys[0],
visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
panes,
}];
}));
}, [
activeKey,
sessions,
sidebarTabPresentations,
titleForSession,
]);
const activePaneLimitReached = Boolean(
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
);
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
onSelectChat(paneKey);
}, [onSelectChat]);
const onSelectSidebarTab = useCallback((tabKey: string) => {
const tab = workbenchTab(workbenchState, tabKey);
if (!tab) return;
const rememberedPaneKey = lastActivePaneByTabRef.current.get(tabKey);
onSelectChat(
rememberedPaneKey && tab.paneKeys.includes(rememberedPaneKey)
? rememberedPaneKey
: tab.paneKeys[0],
);
}, [onSelectChat, workbenchState]);
const onSelectSidebarItem = useCallback((key: string) => {
if (
temporarySessionsRef.current[key]
|| sessions.some((session) => session.key === key)
) {
onSelectChat(key);
return;
}
onSelectSidebarTab(key);
}, [onSelectChat, onSelectSidebarTab, sessions]);
const onSelectSidebarPane = useCallback((_tabKey: string, paneKey: string) => {
onSelectChat(paneKey);
}, [onSelectChat]);
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
updateWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
}, [updateWorkbenchState]);
const onCreateWorkbenchTab = useCallback((paneKey: string) => {
updateWorkbenchState((current) => createWorkbenchTab(current, paneKey));
}, [updateWorkbenchState]);
const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
}, [updateWorkbenchState]);
const onAttachWorkbenchPane = useCallback((
paneKey: string,
tabKey: string,
) => {
updateWorkbenchState((current) => {
const target = workbenchTab(current, tabKey);
if (
!target
|| (!target.explicit && target.paneKeys.length < 2)
|| (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES)
) return current;
return attachWorkbenchPane(current, tabKey, paneKey);
});
}, [updateWorkbenchState]);
useEffect(() => { useEffect(() => {
if (view === "settings") { if (view === "settings") {
@@ -2147,20 +2487,49 @@ function Shell({
: t("app.documentTitle.base"); : t("app.documentTitle.base");
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const pinnedPaneKeys = useMemo(
() => new Set(sidebarState.pinned_keys),
[sidebarState.pinned_keys],
);
const archivedPaneKeys = useMemo(
() => new Set(sidebarState.archived_keys),
[sidebarState.archived_keys],
);
const sidebarPinnedTabKeys = useMemo(() => sidebarTabPresentations
.filter(({ orderedTab }) => orderedTab.paneKeys.some((key) => pinnedPaneKeys.has(key)))
.map(({ rowKey }) => rowKey), [pinnedPaneKeys, sidebarTabPresentations]);
const sidebarArchivedTabKeys = useMemo(() => sidebarTabPresentations
.filter(({ orderedTab }) => orderedTab.paneKeys.every((key) => archivedPaneKeys.has(key)))
.map(({ rowKey }) => rowKey), [archivedPaneKeys, sidebarTabPresentations]);
const activeSidebarKey = activeTabKey
? sidebarTabPresentations.find(({ orderedTab }) => (
orderedTab.tabKey === activeTabKey
))?.rowKey ?? activeKey
: activeKey;
const sidebarProps = { const sidebarProps = {
sessions, sessions: sidebarTopicSessions,
temporarySessions: temporarySessionList, temporarySessions: temporarySessionList,
activeKey: view === "chat" ? activeKey : null, activeKey: view === "chat"
? (temporaryChatActive ? activeKey : activeSidebarKey)
: null,
loading, loading,
newChatActive: view === "chat" && activeKey === null, newChatActive: view === "chat" && activeKey === null,
onNewChat, onNewChat,
onSelect: onSelectChat, onSelect: onSelectSidebarItem,
onCloseTemporaryChat, onCloseTemporaryChat,
onRequestDelete, onRequestDelete,
onRequestDeleteMany,
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onReorderSessions, onRequestRenameTab,
paneGroups: sidebarPaneGroups,
onSelectPane: onSelectSidebarPane,
onCreateTab: mobileWorkbench ? undefined : onCreateWorkbenchTab,
onDetachPane: mobileWorkbench ? undefined : onDetachWorkbenchPane,
onDissolveTab: mobileWorkbench ? undefined : onDissolveWorkbenchTab,
onAttachPane: mobileWorkbench ? undefined : onAttachWorkbenchPane,
onToggleGroup, onToggleGroup,
onRequestRenameProject, onRequestRenameProject,
onNewChatInProject, onNewChatInProject,
@@ -2172,17 +2541,19 @@ function Shell({
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarPinnedTabKeys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarArchivedTabKeys,
pinnedPaneKeys: sidebarState.pinned_keys,
archivedPaneKeys: sidebarState.archived_keys,
sessionOrder: sidebarState.session_order, sessionOrder: sidebarState.session_order,
titleOverrides: sidebarState.title_overrides, titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides, projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups, collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList, runningChatIds: runningChatIdList,
updatedChatIds: updatedChatIdList, updatedChatIds: updatedChatIdList,
viewState: sidebarState.view, viewState: { ...sidebarState.view, sort: automaticSidebarSort },
showArchived: sidebarState.view.show_archived, showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length, archivedCount: sidebarArchivedTabKeys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
}; };
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
@@ -2318,7 +2689,7 @@ function Shell({
<SessionSearchDialog <SessionSearchDialog
open open
onOpenChange={setSessionSearchOpen} onOpenChange={setSessionSearchOpen}
sessions={sessions} sessions={topicSessions}
activeKey={activeKey} activeKey={activeKey}
loading={loading} loading={loading}
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
@@ -2337,6 +2708,43 @@ function Shell({
view !== "chat" && "hidden", view !== "chat" && "hidden",
)} )}
> >
<PaneWorkbench
panes={renderedWorkbenchPanes}
activePaneKey={renderedActivePaneKey}
layout={renderedWorkbenchLayout}
splitRatios={renderedWorkbenchSplitRatios}
chrome={paneChromeEnabled}
showLayoutControl={activeTabVisible}
addPaneDisabled={creatingPane || activePaneLimitReached}
addPaneDisabledLabel={activePaneLimitReached
? t("workbench.paneLimit", {
defaultValue: "Maximum {{count}} panes",
count: MAX_WORKBENCH_PANES,
})
: undefined}
onActivatePane={onActivateWorkbenchPane}
onAddPane={onAddPane}
onLayoutChange={(layout) => {
if (!activeTabKey) return;
updateWorkbenchState((current) => (
setWorkbenchLayout(current, activeTabKey, layout)
));
}}
onPaneOrderChange={(paneKeys) => {
if (!activeTabKey) return;
updateWorkbenchState((current) => (
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
));
}}
onSplitRatiosChange={(splitRatios) => {
if (!activeTabKey) return;
updateWorkbenchState((current) => (
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
));
}}
renderPane={(pane, context) => {
if (!paneChromeEnabled) {
return (
<ThreadShell <ThreadShell
session={activeSession} session={activeSession}
sessions={sessions} sessions={sessions}
@@ -2349,7 +2757,9 @@ function Shell({
} }
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat} onCreateChat={
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
}
onForkChat={temporaryChatActive ? undefined : onForkChat} onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
@@ -2367,6 +2777,67 @@ function Shell({
onOpenModelSettings={onOpenModelSettings} onOpenModelSettings={onOpenModelSettings}
skills={skills} skills={skills}
/> />
);
}
const paneSession = workbenchPaneSessions.find(
(session) => session.key === pane.key,
);
if (!paneSession) return null;
const paneScope = workspaceOverrides[paneSession.chatId]
?? paneSession.workspaceScope
?? workspaces?.default_scope
?? null;
const paneRunning = runningChatIds.has(paneSession.chatId);
return (
<ThreadShell
session={paneSession}
sessions={sessions}
title={pane.title}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggle={!context.active}
hideSidebarToggleForHostChrome={context.active}
hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={!context.active}
hideHeaderTitle
headerActions={context.headerActions}
headerPortalTarget={context.headerPortalTarget}
headerActive={context.active}
composerPortalTarget={context.composerPortalTarget}
composerActive={context.active}
composerInputAriaLabel={t("workbench.composerAria", {
defaultValue: "Message {{title}}",
title: pane.title,
})}
emptyComposerVariant="thread"
workspaceScope={paneScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={paneRunning}
workspaceError={context.active ? workspaceError : null}
onWorkspaceScopeChange={(scope) => {
if (paneRunning) return;
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
setWorkspaceOverrides((current) => ({
...current,
[paneSession.chatId]: next,
}));
client.setWorkspaceScope(paneSession.chatId, next);
}}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
/>
);
}}
/>
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
@@ -2398,7 +2869,8 @@ function Shell({
<Suspense fallback={null}> <Suspense fallback={null}>
<DeleteConfirm <DeleteConfirm
open open
title={pendingDelete.label} title={pendingDelete.items[0]?.label ?? ""}
count={pendingDelete.items.length}
automations={pendingDelete.automations} automations={pendingDelete.automations}
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete} onConfirm={onConfirmDelete}
@@ -2415,6 +2887,19 @@ function Shell({
/> />
</Suspense> </Suspense>
) : null} ) : null}
{pendingTabRename ? (
<Suspense fallback={null}>
<RenameChatDialog
open
title={pendingTabRename.label}
dialogTitle={t("workbench.renameTabTitle")}
description={t("workbench.renameTabDescription")}
placeholder={t("workbench.renameTabPlaceholder")}
onCancel={() => setPendingTabRename(null)}
onConfirm={onConfirmTabRename}
/>
</Suspense>
) : null}
{pendingProjectRename ? ( {pendingProjectRename ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<RenameChatDialog <RenameChatDialog
+41 -14
View File
@@ -9,17 +9,20 @@ type ChannelMessagesModule = {
default?: ChannelMessages; default?: ChannelMessages;
}; };
type ChannelMessagesLoader = () => Promise<ChannelMessagesModule>;
const modules = import.meta.glob<ChannelMessagesModule>( const modules = import.meta.glob<ChannelMessagesModule>(
"../../../nanobot/channels/*/webui/locales/*.json", "../../../nanobot/channels/*/webui/locales/*.json",
{ eager: true },
); );
const loadersByChannel = new Map<
string,
Map<SupportedLocale, ChannelMessagesLoader>
>();
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>(); const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code)); const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
for (const [modulePath, module] of Object.entries(modules)) { for (const [modulePath, loader] of Object.entries(modules)) {
const messages = module.default;
if (!messages) continue;
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/); const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
if (!match) { if (!match) {
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`); throw new Error(`Cannot derive channel locale identity from '${modulePath}'`);
@@ -28,25 +31,27 @@ for (const [modulePath, module] of Object.entries(modules)) {
if (!supportedLocaleCodes.has(locale)) { if (!supportedLocaleCodes.has(locale)) {
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`); throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
} }
const translations = translationsByChannel.get(channel) ?? new Map(); const loaders = loadersByChannel.get(channel) ?? new Map();
if (translations.has(locale as SupportedLocale)) { if (loaders.has(locale as SupportedLocale)) {
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`); throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
} }
translations.set(locale as SupportedLocale, messages); loaders.set(locale as SupportedLocale, loader);
translationsByChannel.set(channel, translations); loadersByChannel.set(channel, loaders);
} }
export function channelLocaleNamespaces(): string[] { export function channelLocaleNamespaces(): string[] {
return [...translationsByChannel.keys()].map(channelNamespace); return [...loadersByChannel.keys()].map(channelNamespace);
} }
export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> { export async function channelLocaleResources(
return Object.fromEntries( locale: SupportedLocale,
[...translationsByChannel.keys()].map((channel) => [ ): Promise<Record<string, ChannelMessages>> {
return Object.fromEntries(await Promise.all(
[...loadersByChannel.keys()].map(async (channel) => [
channelNamespace(channel), channelNamespace(channel),
channelLocaleMessages(channel, locale) ?? {}, await loadChannelLocale(channel, locale),
]), ]),
); ));
} }
export function channelLocaleMessages( export function channelLocaleMessages(
@@ -63,3 +68,25 @@ export function registeredChannelLocales(): ReadonlyMap<
> { > {
return translationsByChannel; return translationsByChannel;
} }
async function loadChannelLocale(
channel: string,
locale: SupportedLocale,
): Promise<ChannelMessages> {
const translations = translationsByChannel.get(channel) ?? new Map();
const loaded = translations.get(locale);
if (loaded) return loaded;
const loaders = loadersByChannel.get(channel);
const loader = loaders?.get(locale) ?? loaders?.get("en");
if (!loader) {
throw new Error(`Channel '${channel}' has no locale loader for '${locale}' or 'en'`);
}
const messages = (await loader()).default;
if (!messages) {
throw new Error(`Channel '${channel}' locale '${locale}' has no default export`);
}
translations.set(locale, messages);
translationsByChannel.set(channel, translations);
return messages;
}
+1 -1
View File
@@ -8,7 +8,7 @@ type ChannelUiContributionModule = {
}; };
const modules = import.meta.glob<ChannelUiContributionModule>( const modules = import.meta.glob<ChannelUiContributionModule>(
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}", "../../../nanobot/channels/*/webui/index.{ts,tsx}",
{ {
eager: true, eager: true,
}, },
File diff suppressed because it is too large Load Diff
@@ -104,37 +104,6 @@ export function splitCapabilityMentionSegments(
return segments.length ? segments : [{ kind: "text", text: value }]; return segments.length ? segments : [{ kind: "text", text: value }];
} }
export function CliAppMentionText({
text,
cliApps,
mcpPresets = [],
sessionMentions = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
sessionMentions?: SessionMention[];
}) {
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets, sessionMentions);
if (!segments.some((segment) => segment.kind !== "text")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CapabilityMentionToken
key={`${segment.kind}-${index}`}
segment={segment}
variant="message"
/>
);
})}
</>
);
}
export function CapabilityMentionToken({ export function CapabilityMentionToken({
segment, segment,
variant, variant,
+18 -2
View File
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps { interface DeleteConfirmProps {
open: boolean; open: boolean;
title: string; title: string;
count?: number;
automations?: SessionAutomationJob[]; automations?: SessionAutomationJob[];
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
export function DeleteConfirm({ export function DeleteConfirm({
open, open,
title, title,
count = 1,
automations = [], automations = [],
onCancel, onCancel,
onConfirm, onConfirm,
@@ -33,6 +35,7 @@ export function DeleteConfirm({
const { t } = useTranslation(); const { t } = useTranslation();
const locale = currentLocale(); const locale = currentLocale();
const hasAutomations = automations.length > 0; const hasAutomations = automations.length > 0;
const multiple = count > 1;
const visibleAutomations = automations.slice(0, 4); const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
return ( return (
@@ -47,11 +50,24 @@ export function DeleteConfirm({
</div> </div>
</div> </div>
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground"> <AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{t("deleteConfirm.title", { title })} {multiple
? t("deleteConfirm.titleMany", {
defaultValue: "Delete {{count}} conversations?",
count,
})
: t("deleteConfirm.title", { title })}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground"> <AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{hasAutomations {hasAutomations
? t("deleteConfirm.automationsDescription") ? multiple
? t("deleteConfirm.automationsDescriptionMany", {
defaultValue: "Linked automations will also be deleted.",
})
: t("deleteConfirm.automationsDescription")
: multiple
? t("deleteConfirm.descriptionMany", {
defaultValue: "This action cannot be undone.",
})
: t("deleteConfirm.description")} : t("deleteConfirm.description")}
</AlertDialogDescription> </AlertDialogDescription>
{hasAutomations ? ( {hasAutomations ? (

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