mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab8783758a |
@@ -57,26 +57,21 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Install channel dependencies
|
||||
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
||||
|
||||
# Channel requirements live in manifests rather than uv.lock. Avoid a
|
||||
# later uv run sync pruning the packages installed by the previous step.
|
||||
- name: Lint with ruff
|
||||
if: matrix.coverage
|
||||
run: uv run --no-sync ruff check nanobot tests conftest.py
|
||||
run: uv run ruff check nanobot tests conftest.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.coverage
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
uv run python -m pytest
|
||||
--cov=nanobot --cov-report=term-missing:skip-covered
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
- name: Run compatibility tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
uv run python -m pytest
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
webui:
|
||||
@@ -110,22 +105,3 @@ jobs:
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
run: bun run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build image with default channel dependencies
|
||||
run: docker build -t nanobot:test .
|
||||
|
||||
- name: Verify default WhatsApp dependencies
|
||||
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
|
||||
|
||||
- name: Verify runtime dependency permissions
|
||||
run: >-
|
||||
docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
|
||||
'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
|
||||
python -m scripts.install_channel_dependencies discord && python -c "import discord"'
|
||||
|
||||
+5
-25
@@ -15,38 +15,18 @@ RUN apt-get update && \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Keep the runtime environment writable by the non-root nanobot user. Enabled
|
||||
# channels may install their manifest-declared dependencies at startup.
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN uv venv --seed "$VIRTUAL_ENV"
|
||||
|
||||
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||
# hook from hatch_build.py even for this metadata-only install.
|
||||
ARG NANOBOT_EXTRAS=
|
||||
ARG NANOBOT_EXTRAS=whatsapp
|
||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||
if [ -n "$NANOBOT_EXTRAS" ]; then \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
||||
--python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \
|
||||
else \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
||||
--python "$VIRTUAL_ENV/bin/python" --no-cache .; \
|
||||
fi && \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \
|
||||
rm -rf nanobot
|
||||
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY scripts/install_channel_dependencies.py scripts/
|
||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache .
|
||||
|
||||
# Preinstall selected channel dependencies from their manifests. A comma-separated
|
||||
# list keeps the image configurable while preserving WhatsApp in the default image.
|
||||
ARG NANOBOT_CHANNELS=whatsapp
|
||||
RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
|
||||
python -m scripts.install_channel_dependencies "$channel"; \
|
||||
done
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
||||
|
||||
# Render deploy template (see render.yaml): committed gateway config that wires
|
||||
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
|
||||
@@ -54,10 +34,10 @@ RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
|
||||
# won't shadow it. Only used when RENDER=true; ignored by local runs.
|
||||
COPY render-config.json ./
|
||||
|
||||
# Create the non-root user and hand ownership of the writable virtualenv to it.
|
||||
# Create non-root user and config directory
|
||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||
mkdir -p /home/nanobot/.nanobot && \
|
||||
chown -R nanobot:nanobot /home/nanobot /app/.venv
|
||||
chown -R nanobot:nanobot /home/nanobot /app
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -2,8 +2,6 @@ x-common-config: &common-config
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
|
||||
volumes:
|
||||
- ~/.nanobot:/home/nanobot/.nanobot
|
||||
cap_drop:
|
||||
|
||||
@@ -235,7 +235,7 @@ Do not add a runtime module directly under `nanobot/channels/`, create a paralle
|
||||
|
||||
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
|
||||
|
||||
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, dependency requirements, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
|
||||
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
|
||||
|
||||
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
|
||||
|
||||
@@ -777,7 +777,6 @@ git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the package as "webhook"
|
||||
nanobot plugins enable webhook
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -46,8 +46,8 @@ The sections below explain what each chat platform requires and provide manual c
|
||||
|
||||
> [!NOTE]
|
||||
> If you are upgrading from a version where chat app SDKs were installed by default,
|
||||
> enable the channel in the same Python environment so nanobot installs its
|
||||
> manifest-declared dependencies:
|
||||
> install the channel extra in the same Python environment before enabling or
|
||||
> restarting that channel:
|
||||
>
|
||||
> ```bash
|
||||
> nanobot plugins enable <channel>
|
||||
@@ -185,7 +185,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
||||
nanobot plugins enable mochat
|
||||
```
|
||||
|
||||
Without these dependencies, Mochat still works through HTTP polling.
|
||||
Without this extra, Mochat still works through HTTP polling.
|
||||
|
||||
**1. Ask nanobot to set up Mochat for you**
|
||||
|
||||
|
||||
@@ -204,8 +204,6 @@ These variables are process-level switches. Set them in the same terminal, servi
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
|
||||
| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. |
|
||||
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
|
||||
|
||||
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
||||
|
||||
@@ -62,22 +62,6 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
|
||||
### Docker Compose
|
||||
|
||||
The default image preinstalls WhatsApp dependencies. To bake other enabled
|
||||
channels into an image (recommended for deployments without PyPI access), pass
|
||||
a comma-separated `NANOBOT_CHANNELS` build argument:
|
||||
|
||||
```bash
|
||||
NANOBOT_CHANNELS=telegram,slack docker compose build
|
||||
```
|
||||
|
||||
The image keeps nanobot in a virtual environment owned by its built-in non-root
|
||||
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
|
||||
startup can therefore install its manifest-declared dependencies. Rebuilding
|
||||
with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying
|
||||
on the container's writable layer. If you override the container with a
|
||||
different `--user`, bake every enabled channel into the image because that UID
|
||||
is not guaranteed write access to the virtual environment.
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||
vim ~/.nanobot/config.json # add API keys
|
||||
@@ -110,12 +94,6 @@ bwrap sandbox is enabled.
|
||||
# Build the image
|
||||
docker build -t nanobot .
|
||||
|
||||
# Or preinstall a regular Python extra such as Bedrock support
|
||||
docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot .
|
||||
|
||||
# Or preinstall dependencies for a specific set of channels
|
||||
docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot .
|
||||
|
||||
# Initialize config (first time only)
|
||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ Use **Settings → Channels** in the WebUI for guided setup. These guides explai
|
||||
| Enable web search | [Configure web search](./configure-web-search.md) |
|
||||
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
|
||||
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
|
||||
| Improve Ollama tool prompt-cache reuse | [Configure Ollama prompt caching](./configure-ollama-prompt-cache.md) |
|
||||
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
|
||||
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
||||
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
# How to Improve Ollama Tool-Calling Prompt Cache Reuse in nanobot
|
||||
|
||||
Some Ollama model templates move or remove tool definitions as a conversation
|
||||
switches between user, assistant, and tool messages. nanobot can send a correct
|
||||
append-only chat request while the model template still renders a different token
|
||||
prefix. On slower local hardware, re-evaluating that prefix can add tens of seconds
|
||||
to an otherwise simple tool-using turn.
|
||||
|
||||
This guide shows how to diagnose that specific pattern and create a derived
|
||||
`llama3.1:8b` tag with a prefix-stable tool template. It does not modify nanobot or
|
||||
overwrite the original Ollama model.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a repeatable two-turn cache check
|
||||
- an optional derived `llama3.1:8b-prefix-stable-v1` Ollama tag
|
||||
- a nanobot model preset that uses the derived tag
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this guide when all of the following are true:
|
||||
|
||||
- direct Ollama responses are reasonably fast;
|
||||
- nanobot becomes slow after the model calls a tool;
|
||||
- Ollama logs show a long main prompt, a much shorter tool follow-up, and low
|
||||
initial cache reuse on the next main prompt;
|
||||
- the model is `llama3.1:8b` with a template that renders concrete tools only for
|
||||
the final user message.
|
||||
|
||||
Do not apply this template to another model family without checking that model's
|
||||
tool-call format first.
|
||||
|
||||
## Diagnose the rendered prompt
|
||||
|
||||
Stop any existing Ollama process, then start a single-slot debug server. A single
|
||||
slot makes the cache sequence easier to read.
|
||||
|
||||
**macOS or Linux**
|
||||
|
||||
```bash
|
||||
OLLAMA_CONTEXT_LENGTH=16384 \
|
||||
OLLAMA_NUM_PARALLEL=1 \
|
||||
OLLAMA_DEBUG=1 \
|
||||
ollama serve
|
||||
```
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
$env:OLLAMA_CONTEXT_LENGTH = "16384"
|
||||
$env:OLLAMA_NUM_PARALLEL = "1"
|
||||
$env:OLLAMA_DEBUG = "1"
|
||||
ollama serve
|
||||
```
|
||||
|
||||
In another terminal, use a fresh session and explicitly request a tool so both
|
||||
turns exercise the agent loop:
|
||||
|
||||
```bash
|
||||
nanobot agent --session cli:ollama-cache-check \
|
||||
--message "Use the exec tool to calculate 2+2, then answer"
|
||||
nanobot agent --session cli:ollama-cache-check \
|
||||
--message "Use the exec tool to calculate 4+7, then answer"
|
||||
```
|
||||
|
||||
In the Ollama output, find each `new prompt` line and the first
|
||||
`cached n_tokens` line that follows it. Later increasing `cached n_tokens` lines
|
||||
are prompt-evaluation progress, not additional initial cache hits.
|
||||
|
||||
A cache-unfriendly tool template may produce a pattern like this:
|
||||
|
||||
```text
|
||||
turn 1 main: 2 / 8460 initially cached
|
||||
turn 1 tool follow-up: 3713 / 3758 initially cached
|
||||
turn 2 main: 3767 / 8519 initially cached
|
||||
```
|
||||
|
||||
The cache is working, but the next main request can reuse only the shorter prompt.
|
||||
Hardware throughput determines how expensive the remaining evaluation is.
|
||||
|
||||
To inspect the API request bodies as well, add
|
||||
`OLLAMA_DEBUG_LOG_REQUESTS=1` before starting Ollama. These logs can contain system
|
||||
prompts, workspace context, and user messages. Keep them local and disable request
|
||||
logging after diagnosis.
|
||||
|
||||
## Why this happens with the stock template
|
||||
|
||||
The tested `llama3.1:8b` template conditionally expands the tool definitions inside
|
||||
a user message:
|
||||
|
||||
```gotemplate
|
||||
{{- if and $.Tools $last }}
|
||||
... render tool definitions ...
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
The first request ends with a user message, so the tools are rendered there. After
|
||||
nanobot appends an assistant tool call and its result, that user message is no
|
||||
longer last, so the same API request history renders without the concrete tool
|
||||
block. On the next user turn, the tools reappear at a new position.
|
||||
|
||||
This is a model-template behavior. At the API boundary, nanobot continues to append
|
||||
the assistant tool call and tool result and sends the same tool definitions.
|
||||
|
||||
## Create a prefix-stable derived model
|
||||
|
||||
Create `PrefixStable.Modelfile` with the content below. The template keeps concrete
|
||||
tool definitions in the system block, where they remain in the same position across
|
||||
user and tool messages.
|
||||
|
||||
```dockerfile
|
||||
FROM llama3.1:8b
|
||||
|
||||
TEMPLATE """{{- if or .System .Tools }}<|start_header_id|>system<|end_header_id|>
|
||||
{{- if .System }}
|
||||
|
||||
{{ .System }}
|
||||
{{- end }}
|
||||
{{- if .Tools }}
|
||||
|
||||
Cutting Knowledge Date: December 2023
|
||||
|
||||
When you receive a tool call response, use the output to format an answer to the original user question.
|
||||
|
||||
You are a helpful assistant with tool calling capabilities.
|
||||
|
||||
Given the following functions, respond with a JSON function call with the proper arguments when a tool is needed.
|
||||
|
||||
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
|
||||
|
||||
{{ range .Tools }}
|
||||
{{- . }}
|
||||
{{ end }}
|
||||
{{- end }}<|eot_id|>
|
||||
{{- end }}
|
||||
{{- range $i, $_ := .Messages }}
|
||||
{{- $last := eq (len (slice $.Messages $i)) 1 }}
|
||||
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
|
||||
|
||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
||||
|
||||
{{ end }}
|
||||
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
|
||||
{{- if .ToolCalls }}
|
||||
{{ range .ToolCalls }}
|
||||
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
|
||||
{{- else }}
|
||||
|
||||
{{ .Content }}
|
||||
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
|
||||
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
|
||||
|
||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
||||
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}"""
|
||||
```
|
||||
|
||||
Create the new tag:
|
||||
|
||||
```bash
|
||||
ollama create llama3.1:8b-prefix-stable-v1 -f PrefixStable.Modelfile
|
||||
ollama list
|
||||
```
|
||||
|
||||
Ollama reuses the existing model layers. The new tag adds a small template and
|
||||
manifest instead of copying the base weights.
|
||||
|
||||
## Select the derived model in nanobot
|
||||
|
||||
Merge this preset into `~/.nanobot/config.json` and select it:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"ollamaPrefixStable": {
|
||||
"label": "Ollama Llama 3.1 prefix-stable",
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1:8b-prefix-stable-v1",
|
||||
"maxTokens": 2048,
|
||||
"contextWindowTokens": 16384,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "ollamaPrefixStable"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify the selected model and repeat the two-turn check:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent --session cli:ollama-stable-check \
|
||||
--message "Use the exec tool to calculate 2+2, then answer"
|
||||
nanobot agent --session cli:ollama-stable-check \
|
||||
--message "Use the exec tool to calculate 4+7, then answer"
|
||||
```
|
||||
|
||||
In one controlled test with Ollama 0.32.1, `llama3.1:8b`, and one slot, the second
|
||||
main request improved from `3767 / 8519` initially cached (44.22%) to
|
||||
`8505 / 8520` (99.82%). The number of re-evaluated tokens fell from 4752 to 15.
|
||||
Treat these numbers as a diagnostic example, not a performance guarantee.
|
||||
|
||||
## Roll back
|
||||
|
||||
Switch `agents.defaults.modelPreset` back to the original preset. When no config
|
||||
uses the derived tag, remove it with:
|
||||
|
||||
```bash
|
||||
ollama rm llama3.1:8b-prefix-stable-v1
|
||||
```
|
||||
|
||||
Removing the derived tag does not remove `llama3.1:8b`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The template above is specific to the tested `llama3.1:8b` tool-call format.
|
||||
- Ollama or the model publisher may update the stock template in a later release.
|
||||
- Validate multiple tool calls, tool errors, parallel calls, and long conversations
|
||||
before using a custom template for unattended workloads.
|
||||
- A higher cache ratio reduces prompt evaluation, but model generation, tool
|
||||
execution, process startup, and storage can still dominate end-to-end latency.
|
||||
- Multiple Ollama slots change cache scheduling and may produce different results.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Provider Cookbook: Ollama Local Model](../provider-cookbook.md#recipe-ollama-local-model)
|
||||
- [Providers and Models: Ollama](../providers.md#ollama)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
@@ -431,13 +431,7 @@ curl -sS http://localhost:11434/v1/models
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If every response is slow, try a smaller local model or lower `contextWindowTokens`.
|
||||
|
||||
If direct Ollama responses are fast but tool-using nanobot turns repeatedly evaluate
|
||||
thousands of prompt tokens, the model's chat template may be moving its tool
|
||||
definitions between requests. See
|
||||
[Improve Ollama Tool-Calling Prompt Cache Reuse](./guides/configure-ollama-prompt-cache.md)
|
||||
for a diagnostic procedure and an optional model-specific workaround.
|
||||
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`.
|
||||
|
||||
## Recipe: vLLM or LM Studio
|
||||
|
||||
|
||||
@@ -331,13 +331,6 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
||||
|
||||
Most Ollama setups do not require an API key.
|
||||
|
||||
Ollama renders the OpenAI-compatible messages and tools through each model's chat
|
||||
template. If ordinary model responses are fast but tool-using turns show low prompt
|
||||
cache reuse, diagnose the rendered template before changing nanobot's context or
|
||||
memory settings. The
|
||||
[Ollama prompt-cache guide](./guides/configure-ollama-prompt-cache.md) explains the
|
||||
log pattern and a tested `llama3.1:8b` workaround.
|
||||
|
||||
### vLLM or Other Local OpenAI-Compatible Server
|
||||
|
||||
```json
|
||||
|
||||
@@ -40,10 +40,6 @@ from nanobot.security.workspace_policy import is_path_within
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
_RM_COMMAND_RE = re.compile(r"\brm\b")
|
||||
_SHELL_COMMAND_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|\r\n])")
|
||||
_SHELL_TOKEN_RE = re.compile(r'''"[^"]*"|'[^']*'|[^\s]+''')
|
||||
|
||||
|
||||
def _reap_pid(pid: int) -> None:
|
||||
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
|
||||
@@ -214,6 +210,7 @@ class ExecTool(Tool):
|
||||
self.working_dir = working_dir
|
||||
self.sandbox = sandbox
|
||||
self.deny_patterns = (deny_patterns or []) + [
|
||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||
r"\brmdir\s+/s\b", # rmdir /s
|
||||
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
|
||||
@@ -707,74 +704,6 @@ class ExecTool(Tool):
|
||||
env[key] = val
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def _contains_unscoped_recursive_rm(cls, command: str) -> bool:
|
||||
"""Return whether ``command`` contains recursive rm outside a scoped /tmp target.
|
||||
|
||||
The exec guard deliberately remains conservative for recursive deletion, but
|
||||
test and build scripts routinely clean their own named directories below
|
||||
``/tmp``. Treat only static, direct ``/tmp/<name>`` targets as scoped cleanup.
|
||||
Any ambiguous invocation (variables, traversal, broad globs, nested paths,
|
||||
mixed targets) stays blocked.
|
||||
"""
|
||||
for match in _RM_COMMAND_RE.finditer(command):
|
||||
tail = command[match.end():]
|
||||
segment = _SHELL_COMMAND_SEPARATOR_RE.split(tail, maxsplit=1)[0]
|
||||
tokens = _SHELL_TOKEN_RE.findall(segment)
|
||||
recursive = False
|
||||
targets: list[str] = []
|
||||
parsing_options = True
|
||||
unsafe_redirect = False
|
||||
|
||||
for raw_token in tokens:
|
||||
token = raw_token.strip().strip("\"'")
|
||||
if not token:
|
||||
continue
|
||||
if token == "--" and parsing_options:
|
||||
parsing_options = False
|
||||
continue
|
||||
if parsing_options and token.startswith("--"):
|
||||
recursive = recursive or token == "--recursive"
|
||||
continue
|
||||
if parsing_options and re.fullmatch(r"-[a-z]+", token):
|
||||
recursive = recursive or "r" in token[1:]
|
||||
continue
|
||||
|
||||
parsing_options = False
|
||||
if token.startswith("#"):
|
||||
break
|
||||
if re.match(r"^\d*[<>]", token):
|
||||
redirect_target = re.sub(r"^\d*[<>]+", "", token)
|
||||
if redirect_target and redirect_target != "/dev/null":
|
||||
unsafe_redirect = True
|
||||
continue
|
||||
targets.append(token)
|
||||
|
||||
if recursive and (
|
||||
unsafe_redirect
|
||||
or not targets
|
||||
or not all(cls._is_scoped_tmp_cleanup_target(target) for target in targets)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_scoped_tmp_cleanup_target(raw_target: str) -> bool:
|
||||
"""Accept a static, specifically named descendant of the POSIX /tmp root."""
|
||||
target = raw_target.strip().rstrip("\"'),")
|
||||
if not target.startswith("/tmp/"):
|
||||
return False
|
||||
|
||||
relative = target.removeprefix("/tmp/")
|
||||
if not relative or any(char in relative for char in ("$", "`", "\\", "[", "{")):
|
||||
return False
|
||||
if "/" in relative or relative in {".", ".."}:
|
||||
return False
|
||||
|
||||
literal_prefix = re.split(r"[*?]", relative, maxsplit=1)[0]
|
||||
return any(char.isalnum() or char in "_-" for char in literal_prefix)
|
||||
|
||||
def _guard_command(
|
||||
self,
|
||||
command: str,
|
||||
@@ -794,9 +723,6 @@ class ExecTool(Tool):
|
||||
re.fullmatch(p, lower) for p in self.allow_patterns
|
||||
)
|
||||
if not explicitly_allowed:
|
||||
if self._contains_unscoped_recursive_rm(lower):
|
||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
||||
|
||||
for pattern in self.deny_patterns:
|
||||
if re.search(pattern, lower):
|
||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
||||
|
||||
@@ -253,6 +253,75 @@ async def test_bootstrap_returns_token_for_localhost(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_companion_launch_uses_private_refreshable_session(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
port = _free_port()
|
||||
channel = _ch(bus, port=port, tokenIssueSecret="persistent-secret")
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
status = await _http_get(f"http://127.0.0.1:{port}/webui/companion/status")
|
||||
assert status.status_code == 200
|
||||
assert status.json()["ready"] is True
|
||||
assert isinstance(status.json()["version"], str)
|
||||
|
||||
navigation_headers = {
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Site": "none",
|
||||
}
|
||||
launch = await _http_get(
|
||||
f"http://127.0.0.1:{port}/webui/companion/open",
|
||||
headers=navigation_headers,
|
||||
)
|
||||
assert launch.status_code == 302
|
||||
assert launch.headers["cache-control"] == "no-store"
|
||||
assert launch.headers["location"] == "/#/"
|
||||
cookie = launch.headers["set-cookie"]
|
||||
assert cookie.startswith(f"nanobot_companion_{port}=nbcs_")
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=Strict" in cookie
|
||||
companion_cookie = cookie.split(";", 1)[0]
|
||||
|
||||
bootstrap_headers = {"Cookie": companion_cookie}
|
||||
accepted = await _http_get(
|
||||
f"http://127.0.0.1:{port}/webui/bootstrap",
|
||||
headers=bootstrap_headers,
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
|
||||
refreshed = await _http_get(
|
||||
f"http://127.0.0.1:{port}/webui/bootstrap",
|
||||
headers=bootstrap_headers,
|
||||
)
|
||||
assert refreshed.status_code == 200
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_companion_rejects_cross_site_launch(bus: MagicMock) -> None:
|
||||
port = _free_port()
|
||||
channel = _ch(bus, port=port)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/webui/companion/open",
|
||||
headers={
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert channel.gateway.tokens.companion_sessions == {}
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_routes_require_bearer_token(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
|
||||
**Question 2 — Optional dependencies:**
|
||||
|
||||
```
|
||||
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, langfuse, olostep. Channel dependencies are installed from their manifests when the gateway starts."
|
||||
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, langfuse, olostep. Channel dependencies are installed from their manifests when the WebUI gateway starts."
|
||||
```
|
||||
|
||||
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Security policy for launching the local WebUI from a browser extension."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.webui.http_utils import case_insensitive_header
|
||||
|
||||
STATUS_PATH = "/webui/companion/status"
|
||||
OPEN_PATH = "/webui/companion/open"
|
||||
SESSION_COOKIE_PREFIX = "nanobot_companion_"
|
||||
SESSION_TTL_SECONDS = 12 * 60 * 60
|
||||
|
||||
|
||||
def session_cookie_name(port: int) -> str:
|
||||
"""Keep companion sessions isolated when several local WebUIs use different ports."""
|
||||
return f"{SESSION_COOKIE_PREFIX}{port}"
|
||||
|
||||
|
||||
def is_top_level_user_navigation(headers: Any) -> bool:
|
||||
"""Accept address-bar or extension-created tabs, not cross-site window.open calls."""
|
||||
mode = case_insensitive_header(headers, "Sec-Fetch-Mode").lower()
|
||||
destination = case_insensitive_header(headers, "Sec-Fetch-Dest").lower()
|
||||
site = case_insensitive_header(headers, "Sec-Fetch-Site").lower()
|
||||
return mode == "navigate" and destination == "document" and site == "none"
|
||||
@@ -17,8 +17,10 @@ class GatewayTokenStore:
|
||||
"""Own short-lived WebSocket and WebUI API tokens for one gateway process."""
|
||||
|
||||
max_tokens: int = 10_000
|
||||
max_companion_sessions: int = 64
|
||||
issued_tokens: dict[str, float] = field(default_factory=dict)
|
||||
api_tokens: dict[str, float] = field(default_factory=dict)
|
||||
companion_sessions: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def check_api_token(self, request: WsRequest) -> bool:
|
||||
self._purge_expired_api_tokens()
|
||||
@@ -42,6 +44,10 @@ class GatewayTokenStore:
|
||||
return False
|
||||
return True
|
||||
|
||||
def can_issue_companion_session(self) -> bool:
|
||||
self._purge_expired_companion_sessions()
|
||||
return len(self.companion_sessions) < self.max_companion_sessions
|
||||
|
||||
def issue_token(self, ttl_s: int | float) -> str:
|
||||
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
|
||||
expiry = time.monotonic() + float(ttl_s)
|
||||
@@ -54,6 +60,11 @@ class GatewayTokenStore:
|
||||
self.api_tokens[token_value] = expiry
|
||||
return token_value
|
||||
|
||||
def issue_companion_session(self, ttl_s: int | float) -> str:
|
||||
token_value = f"nbcs_{secrets.token_urlsafe(32)}"
|
||||
self.companion_sessions[token_value] = time.monotonic() + float(ttl_s)
|
||||
return token_value
|
||||
|
||||
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
||||
if not token_value:
|
||||
return False
|
||||
@@ -65,9 +76,17 @@ class GatewayTokenStore:
|
||||
return False
|
||||
return True
|
||||
|
||||
def companion_session_is_valid(self, token_value: str | None) -> bool:
|
||||
if not token_value:
|
||||
return False
|
||||
self._purge_expired_companion_sessions()
|
||||
expiry = self.companion_sessions.get(token_value)
|
||||
return expiry is not None and time.monotonic() <= expiry
|
||||
|
||||
def clear(self) -> None:
|
||||
self.issued_tokens.clear()
|
||||
self.api_tokens.clear()
|
||||
self.companion_sessions.clear()
|
||||
|
||||
def _purge_expired_api_tokens(self) -> None:
|
||||
now = time.monotonic()
|
||||
@@ -81,6 +100,12 @@ class GatewayTokenStore:
|
||||
if now > expiry:
|
||||
self.issued_tokens.pop(token_key, None)
|
||||
|
||||
def _purge_expired_companion_sessions(self) -> None:
|
||||
now = time.monotonic()
|
||||
for token_key, expiry in list(self.companion_sessions.items()):
|
||||
if now > expiry:
|
||||
self.companion_sessions.pop(token_key, None)
|
||||
|
||||
|
||||
def token_response_payload(token: str, expires_in: Any) -> dict[str, Any]:
|
||||
return {"token": token, "expires_in": expires_in}
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import email.utils
|
||||
import hmac
|
||||
import http
|
||||
import http.cookies
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
@@ -41,6 +42,20 @@ def case_insensitive_header(headers: Any, key: str) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def request_cookie(headers: Any, name: str) -> str | None:
|
||||
"""Read one request cookie without accepting malformed cookie syntax."""
|
||||
raw = case_insensitive_header(headers, "Cookie")
|
||||
if not raw:
|
||||
return None
|
||||
cookies = http.cookies.SimpleCookie()
|
||||
try:
|
||||
cookies.load(raw)
|
||||
except http.cookies.CookieError:
|
||||
return None
|
||||
morsel = cookies.get(name)
|
||||
return morsel.value if morsel else None
|
||||
|
||||
|
||||
def safe_host_header(value: str) -> str:
|
||||
"""Return a safe Host header value, or empty when it should not be echoed."""
|
||||
value = value.strip()
|
||||
|
||||
@@ -23,12 +23,14 @@ from loguru import logger
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob, CronSchedule
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.triggers.local_types import LocalTrigger
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.webui import browser_companion
|
||||
from nanobot.webui.file_preview import (
|
||||
WebUIFilePreviewError,
|
||||
file_preview_availability_payload,
|
||||
@@ -71,6 +73,9 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
query_first as _query_first,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
request_cookie as _request_cookie,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
safe_host_header as _safe_host_header,
|
||||
)
|
||||
@@ -245,6 +250,12 @@ class GatewayHTTPHandler:
|
||||
if got == "/webui/bootstrap":
|
||||
return self._handle_bootstrap(connection, request)
|
||||
|
||||
if got == browser_companion.STATUS_PATH:
|
||||
return self._handle_companion_status(connection, request)
|
||||
|
||||
if got == browser_companion.OPEN_PATH:
|
||||
return self._handle_companion_open(connection, request)
|
||||
|
||||
# Settings routes (delegated)
|
||||
response = await self.settings_routes.dispatch(connection, request, got)
|
||||
if response is not None:
|
||||
@@ -319,16 +330,56 @@ class GatewayHTTPHandler:
|
||||
|
||||
# -- Bootstrap ----------------------------------------------------------
|
||||
|
||||
def _handle_companion_status(self, connection: Any, request: Any) -> Response:
|
||||
if not _is_local_browser_request(connection, request.headers):
|
||||
return _http_error(403, "companion is localhost-only")
|
||||
return _http_json_response({"ready": True, "version": __version__})
|
||||
|
||||
def _handle_companion_open(self, connection: Any, request: Any) -> Response:
|
||||
if not _is_local_browser_request(connection, request.headers):
|
||||
return _http_error(403, "companion is localhost-only")
|
||||
if not browser_companion.is_top_level_user_navigation(request.headers):
|
||||
return _http_error(403, "companion launch requires a direct browser navigation")
|
||||
cookie_name = browser_companion.session_cookie_name(self.config.port)
|
||||
companion_session = _request_cookie(request.headers, cookie_name)
|
||||
if not self.tokens.companion_session_is_valid(companion_session):
|
||||
if not self.tokens.can_issue_companion_session():
|
||||
return _http_error(429, "too many companion sessions")
|
||||
companion_session = self.tokens.issue_companion_session(
|
||||
browser_companion.SESSION_TTL_SECONDS
|
||||
)
|
||||
cookie = (
|
||||
f"{cookie_name}={companion_session}; "
|
||||
"Path=/webui; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
return _http_response(
|
||||
b"",
|
||||
status=302,
|
||||
extra_headers=[
|
||||
("Location", "/#/"),
|
||||
("Set-Cookie", cookie),
|
||||
("Cache-Control", "no-store"),
|
||||
("Referrer-Policy", "no-referrer"),
|
||||
("X-Content-Type-Options", "nosniff"),
|
||||
],
|
||||
)
|
||||
|
||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||
is_local_browser = _is_local_browser_request(connection, request.headers)
|
||||
if secret:
|
||||
cookie_name = browser_companion.session_cookie_name(self.config.port)
|
||||
companion_authenticated = self.tokens.companion_session_is_valid(
|
||||
_request_cookie(request.headers, cookie_name)
|
||||
)
|
||||
if companion_authenticated and not is_local_browser:
|
||||
return _http_error(403, "companion session is localhost-only")
|
||||
if secret and not companion_authenticated:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not is_local_browser:
|
||||
elif not secret and not is_local_browser:
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
|
||||
api_token_allowed = bool(secret) or is_local_browser
|
||||
api_token_allowed = companion_authenticated or bool(secret) or is_local_browser
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Install channel manifest dependencies for repository build and CI jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
from nanobot.optional_features import ensure_enabled_channel_dependencies
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Install selected channel dependencies without changing channel configuration."""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
print("Pass channel names or --all-channels.", file=sys.stderr)
|
||||
return 2
|
||||
if "--all-channels" in args and args != ["--all-channels"]:
|
||||
print("Pass channel names or --all-channels, not both.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
plugins = discover_plugins()
|
||||
names = set(plugins) if args == ["--all-channels"] else set(args)
|
||||
unknown = sorted(names - set(plugins))
|
||||
if unknown:
|
||||
print(f"Unknown channels: {', '.join(unknown)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
failures = ensure_enabled_channel_dependencies(names, plugins)
|
||||
for name, message in sorted(failures.items()):
|
||||
print(f"{name}: {message}", file=sys.stderr)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1499,63 +1499,6 @@ def test_plugins_enable_skips_install_when_extra_is_present(monkeypatch, tmp_pat
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
def test_repository_dependency_installer_selects_all_channel_manifests(monkeypatch):
|
||||
from scripts import install_channel_dependencies as dependencies
|
||||
|
||||
plugins = {
|
||||
"second": ChannelPlugin(
|
||||
name="second",
|
||||
display_name="Second",
|
||||
runtime="missing.second.runtime:SecondChannel",
|
||||
dependencies=("second-sdk>=2",),
|
||||
),
|
||||
"first": ChannelPlugin(
|
||||
name="first",
|
||||
display_name="First",
|
||||
runtime="missing.first.runtime:FirstChannel",
|
||||
dependencies=("first-sdk>=1",),
|
||||
),
|
||||
}
|
||||
prepared: list[tuple[set[str], dict[str, ChannelPlugin]]] = []
|
||||
monkeypatch.setattr(dependencies, "discover_plugins", lambda: plugins)
|
||||
monkeypatch.setattr(
|
||||
dependencies,
|
||||
"ensure_enabled_channel_dependencies",
|
||||
lambda names, discovered: prepared.append((names, discovered)) or {},
|
||||
)
|
||||
|
||||
assert dependencies.main(["--all-channels"]) == 0
|
||||
assert prepared == [(set(plugins), plugins)]
|
||||
|
||||
|
||||
def test_repository_dependency_installer_rejects_unknown_channel(monkeypatch, capsys):
|
||||
from scripts import install_channel_dependencies as dependencies
|
||||
|
||||
monkeypatch.setattr(dependencies, "discover_plugins", lambda: {})
|
||||
|
||||
assert dependencies.main(["missing"]) == 2
|
||||
assert "Unknown channels: missing" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_repository_dependency_installer_propagates_install_failure(monkeypatch, capsys):
|
||||
from scripts import install_channel_dependencies as dependencies
|
||||
|
||||
plugin = ChannelPlugin(
|
||||
name="demo",
|
||||
display_name="Demo",
|
||||
runtime="missing.demo.runtime:DemoChannel",
|
||||
)
|
||||
monkeypatch.setattr(dependencies, "discover_plugins", lambda: {"demo": plugin})
|
||||
monkeypatch.setattr(
|
||||
dependencies,
|
||||
"ensure_enabled_channel_dependencies",
|
||||
lambda _names, _plugins: {"demo": "dependency install failed"},
|
||||
)
|
||||
|
||||
assert dependencies.main(["demo"]) == 1
|
||||
assert "demo: dependency install failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_plugins_disable_channel_writes_config(monkeypatch, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
|
||||
@@ -2,94 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
|
||||
def test_deny_patterns_block_rm_rf():
|
||||
"""Baseline: rm -rf is blocked by default deny list."""
|
||||
tool = ExecTool()
|
||||
result = tool._guard_command("rm -rf /", "/tmp")
|
||||
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
|
||||
assert result is not None
|
||||
assert "deny pattern filter" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"rm -rf /tmp/nanobot-test",
|
||||
"rm -fr /tmp/nanobot-test-*",
|
||||
"rm --recursive --force /tmp/nanobot-test-cache",
|
||||
"echo setup && rm -rf /tmp/nanobot-test; echo done",
|
||||
"bash -lc 'pytest tests; rm -rf /tmp/nanobot-test'",
|
||||
"rm -rf '/tmp/nanobot test' 2>/dev/null",
|
||||
],
|
||||
)
|
||||
def test_deny_patterns_allow_scoped_tmp_cleanup(command):
|
||||
"""Named, static /tmp descendants are safe enough for test cleanup."""
|
||||
tool = ExecTool()
|
||||
assert tool._guard_command(command, "/tmp") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"rm -rf /tmp",
|
||||
"rm -rf /tmp/*",
|
||||
"rm -rf /tmp/nanobot-test/../../etc",
|
||||
"rm -rf /tmp/nanobot-test/cache",
|
||||
"rm -rf /tmp/$TARGET",
|
||||
"rm -rf /tmp/nanobot-test /etc",
|
||||
"rm -rf /tmp/nanobot-test >/etc/passwd",
|
||||
"echo setup && rm -rf /etc",
|
||||
],
|
||||
)
|
||||
def test_deny_patterns_block_unscoped_recursive_rm(command):
|
||||
"""Broad, dynamic, traversing, or mixed recursive deletions remain blocked."""
|
||||
tool = ExecTool()
|
||||
result = tool._guard_command(command, "/tmp")
|
||||
assert result is not None
|
||||
assert "deny pattern filter" in result.lower()
|
||||
|
||||
|
||||
def test_deny_patterns_allow_non_recursive_rm_f():
|
||||
"""The recursive-delete guard must not mistake rm -f for rm -rf."""
|
||||
tool = ExecTool()
|
||||
assert tool._guard_command("rm -f /tmp/nanobot-test.log", "/tmp") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX rm and /tmp syntax")
|
||||
async def test_exec_runs_scoped_tmp_cleanup():
|
||||
"""A real exec call can remove its own directly named temporary directory."""
|
||||
with tempfile.TemporaryDirectory(prefix="nanobot-exec-cleanup-", dir="/tmp") as temp_dir:
|
||||
target = Path(temp_dir)
|
||||
(target / "scratch.txt").write_text("scratch")
|
||||
tool = ExecTool(timeout=5)
|
||||
|
||||
result = await tool.execute(command=f"rm -rf {shlex.quote(temp_dir)}")
|
||||
|
||||
assert "deny pattern filter" not in result.lower()
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_allow_patterns_bypass_deny():
|
||||
"""allow_patterns take priority: matching command skips deny check."""
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/opt/build"])
|
||||
result = tool._guard_command("rm -rf /opt/build", "/tmp")
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/.*"])
|
||||
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_allow_patterns_must_match_to_bypass():
|
||||
"""Non-matching allow_patterns do NOT bypass deny."""
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/build"])
|
||||
result = tool._guard_command("rm -rf /opt/build", "/tmp")
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/opt/"])
|
||||
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
|
||||
assert result is not None
|
||||
assert "deny pattern filter" in result.lower()
|
||||
|
||||
@@ -100,15 +34,7 @@ def test_extra_deny_patterns_from_config():
|
||||
# ping is blocked by extra deny
|
||||
assert tool._guard_command("ping example.com", "/tmp") is not None
|
||||
# rm -rf still blocked by built-in deny
|
||||
assert tool._guard_command("rm -rf /", "/tmp") is not None
|
||||
|
||||
|
||||
def test_extra_deny_patterns_can_block_scoped_tmp_cleanup():
|
||||
"""User-configured policy still takes precedence over the built-in exception."""
|
||||
tool = ExecTool(deny_patterns=[r"\brm\b"])
|
||||
result = tool._guard_command("rm -rf /tmp/nanobot-test", "/tmp")
|
||||
assert result is not None
|
||||
assert "deny pattern filter" in result.lower()
|
||||
assert tool._guard_command("rm -rf /tmp/x", "/tmp") is not None
|
||||
|
||||
|
||||
def test_allow_patterns_bypass_extra_deny():
|
||||
@@ -158,6 +84,6 @@ def test_deny_patterns_search_original_command_with_quoted_hash():
|
||||
|
||||
def test_allow_patterns_fullmatch_allows_exact_command():
|
||||
"""A full-command allow pattern can still exempt an exact denied command."""
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/opt/build"])
|
||||
result = tool._guard_command("rm -rf /opt/build", "/tmp")
|
||||
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/build"])
|
||||
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
|
||||
assert result is None
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from nanobot.webui.browser_companion import is_top_level_user_navigation, session_cookie_name
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
|
||||
|
||||
def test_companion_session_can_refresh_until_expiry() -> None:
|
||||
tokens = GatewayTokenStore()
|
||||
session = tokens.issue_companion_session(30)
|
||||
assert tokens.companion_session_is_valid(session) is True
|
||||
assert tokens.companion_session_is_valid(session) is True
|
||||
|
||||
|
||||
def test_companion_session_capacity_is_bounded() -> None:
|
||||
tokens = GatewayTokenStore(max_companion_sessions=1)
|
||||
tokens.issue_companion_session(30)
|
||||
assert tokens.can_issue_companion_session() is False
|
||||
|
||||
|
||||
def test_companion_navigation_policy_is_fail_closed() -> None:
|
||||
direct = {
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Site": "none",
|
||||
}
|
||||
assert is_top_level_user_navigation(direct) is True
|
||||
assert is_top_level_user_navigation({}) is False
|
||||
assert is_top_level_user_navigation({**direct, "Sec-Fetch-Site": "cross-site"}) is False
|
||||
|
||||
|
||||
def test_companion_cookie_is_isolated_by_webui_port() -> None:
|
||||
assert session_cookie_name(8765) != session_cookie_name(8766)
|
||||
@@ -40,6 +40,8 @@ const CODE_FONT_STACK = [
|
||||
].join(", ");
|
||||
|
||||
const ANSI_LANGUAGES = new Set(["ansi", "ansi-output"]);
|
||||
const CODE_SURFACE_LIGHT = "#f4f4f5";
|
||||
const CODE_SURFACE_DARK = "#27272a";
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
@@ -79,11 +81,15 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
language={language || "text"}
|
||||
style={transparentTheme}
|
||||
customStyle={{
|
||||
background: "transparent",
|
||||
background: chrome === "none"
|
||||
? "transparent"
|
||||
: isDark
|
||||
? CODE_SURFACE_DARK
|
||||
: CODE_SURFACE_LIGHT,
|
||||
margin: 0,
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem 3.5rem 1rem 1.25rem",
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
fontSize: "13px",
|
||||
fontSize: chrome === "none" ? "13px" : "0.875rem",
|
||||
lineHeight: chrome === "none" ? 1.55 : 1.6,
|
||||
tabSize: 2,
|
||||
}}
|
||||
@@ -142,11 +148,10 @@ function CodeTextBlock({
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"m-0 overflow-x-auto bg-transparent font-mono text-[13px] text-foreground/90",
|
||||
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
|
||||
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
|
||||
chrome === "default"
|
||||
? "py-4 pl-5 pr-14 leading-[1.6]"
|
||||
: "p-3 leading-[1.55]",
|
||||
chrome === "default" ? "bg-zinc-100 dark:bg-zinc-800" : "bg-transparent",
|
||||
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
@@ -188,7 +193,6 @@ export function CodeBlock({
|
||||
const hasChrome = chrome === "default";
|
||||
const renderAnsi = shouldRenderAnsi(language, code);
|
||||
const syntaxLanguage = normalizeCodeLanguage(language);
|
||||
const copyLabel = copied ? t("code.copied") : t("code.copyAria");
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
void copyTextToClipboard(renderAnsi ? stripAnsi(code) : code).then((ok) => {
|
||||
@@ -201,12 +205,44 @@ export function CodeBlock({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose relative overflow-hidden",
|
||||
hasChrome && "rounded-[18px] bg-secondary/70",
|
||||
"not-prose overflow-hidden",
|
||||
hasChrome && "rounded-lg border",
|
||||
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
|
||||
className,
|
||||
)}
|
||||
data-language={language || t("code.fallbackLanguage")}
|
||||
>
|
||||
{hasChrome ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 pb-1.5 pt-2 text-xs font-medium",
|
||||
isDark
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
|
||||
isDark
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
)}
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{renderAnsi ? (
|
||||
<CodeTextBlock
|
||||
code={code}
|
||||
@@ -243,25 +279,6 @@ export function CodeBlock({
|
||||
testId="plain-code-fallback"
|
||||
/>
|
||||
)}
|
||||
{hasChrome ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"absolute right-2.5 top-2.5 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full",
|
||||
"text-muted-foreground/75 transition-colors hover:bg-background/70 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
||||
)}
|
||||
aria-label={copyLabel}
|
||||
title={copyLabel}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function buildDisplayUnits(
|
||||
});
|
||||
}
|
||||
|
||||
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
const flags = new Array<boolean>(units.length).fill(true);
|
||||
let hasLaterUnitBeforeUser = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
@@ -63,7 +63,7 @@ export function ThreadMessages({
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
[forkBoundaryMessageCount, units],
|
||||
);
|
||||
const forkFlags = useMemo(() => assistantForkFlags(units), [units]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
[isStreaming, units],
|
||||
@@ -90,7 +90,7 @@ export function ThreadMessages({
|
||||
? unit.message.id
|
||||
: undefined;
|
||||
const forkIndex =
|
||||
unit.type === "message" && unit.message.role === "assistant" && forkFlags[index]
|
||||
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
|
||||
? nextUserIndex
|
||||
: undefined;
|
||||
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
||||
@@ -112,6 +112,11 @@ export function ThreadMessages({
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
showCopyAction={
|
||||
unit.message.role === "assistant"
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
|
||||
@@ -48,20 +48,8 @@ describe("CodeBlock", () => {
|
||||
|
||||
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ts")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("ts")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
|
||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("bg-transparent");
|
||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14");
|
||||
|
||||
const container = screen.getByTestId("plain-code-fallback").closest(".not-prose");
|
||||
expect(container).toHaveClass("relative", "rounded-[18px]", "bg-secondary/70");
|
||||
expect(container).not.toHaveClass("border");
|
||||
expect(container).toHaveAttribute("data-language", "ts");
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "Copy code" });
|
||||
expect(copyButton.parentElement).toBe(container);
|
||||
expect(copyButton).toHaveClass("absolute", "h-8", "w-8", "rounded-full");
|
||||
expect(copyButton).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("can render without chat-style chrome for file previews", () => {
|
||||
@@ -127,11 +115,8 @@ describe("CodeBlock", () => {
|
||||
|
||||
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("ansi-code")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ansi-code").closest(".not-prose")).toHaveAttribute(
|
||||
"data-language",
|
||||
"ansi",
|
||||
);
|
||||
expect(screen.queryByText("ansi")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("ansi-code").closest(".not-prose")).toBeTruthy();
|
||||
expect(screen.getByText("ansi")).toBeInTheDocument();
|
||||
expect(screen.getByText("PASS")).toHaveStyle({ color: "#0dbc79" });
|
||||
expect(screen.getByText("<script>alert(1)</script>")).toBeInTheDocument();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
@@ -198,7 +183,7 @@ describe("CodeBlock", () => {
|
||||
await user.click(screen.getByRole("button", { name: /copy/i }));
|
||||
|
||||
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Copied")).toBeInTheDocument();
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "clipboard");
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
|
||||
@@ -163,8 +163,7 @@ describe("MarkdownTextRenderer", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("code without language")).toBeInTheDocument();
|
||||
expect(screen.queryByText("text")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".not-prose")).toHaveAttribute("data-language", "text");
|
||||
expect(screen.getByText("text")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("pre")).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
assistantForkFlags,
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
unitKeysForDisplay,
|
||||
@@ -747,7 +747,7 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy on every assistant slice while keeping fork on the last slice", () => {
|
||||
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "early",
|
||||
@@ -771,26 +771,19 @@ describe("ThreadMessages", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
onForkFromMessage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy on adjacent assistant text slices", () => {
|
||||
it("shows copy only on the second assistant when two text slices appear before user", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
||||
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
|
||||
];
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses turn ids as activity grouping boundaries when available", () => {
|
||||
@@ -817,7 +810,7 @@ describe("ThreadMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes final assistant fork flags with user-boundary semantics", () => {
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
|
||||
@@ -834,7 +827,7 @@ describe("ThreadMessages", () => {
|
||||
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
|
||||
]);
|
||||
|
||||
const flags = assistantForkFlags(units);
|
||||
const flags = assistantCopyFlags(units);
|
||||
const assistantFlags = units
|
||||
.map((unit, index) =>
|
||||
unit.type === "message" && unit.message.role === "assistant"
|
||||
|
||||
Reference in New Issue
Block a user