diff --git a/README.md b/README.md
index fdbd5cf..5bd11f8 100644
--- a/README.md
+++ b/README.md
@@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
## Multiple Instances
-Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage.
+Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run.
### Quick Start
@@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
```
-### Complete Isolation
+### Path Resolution
-When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation:
+When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
-| Component | Isolation | Example |
-|-----------|-----------|---------|
-| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` |
-| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` |
-| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` |
-| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` |
-| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` |
+| Component | Resolved From | Example |
+|-----------|---------------|---------|
+| **Config** | `--config` path | `~/.nanobot-A/config.json` |
+| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
+| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
+| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
-### Setup Example
+### How It Works
-**1. Create directory structure for each instance:**
+- `--config` selects which config file to load
+- By default, the workspace comes from `agents.defaults.workspace` in that config
+- If you pass `--workspace`, it overrides the workspace from the config file
-```bash
-# Instance A
-mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json
+### Minimal Setup
-# Instance B
-mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-discord/config.json
-```
+1. Copy your base config into a new instance directory.
+2. Set a different `agents.defaults.workspace` for that instance.
+3. Start the instance with `--config`.
-**2. Configure each instance:**
+Example config:
-Edit `~/.nanobot-telegram/config.json`:
```json
{
"agents": {
@@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`:
}
```
-Edit `~/.nanobot-discord/config.json`:
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.nanobot-discord/workspace",
- "model": "anthropic/claude-opus-4"
- }
- },
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_DISCORD_BOT_TOKEN"
- }
- },
- "gateway": {
- "port": 18791
- }
-}
-```
-
-**3. Start instances:**
+Start separate instances:
```bash
-# Terminal 1
nanobot gateway --config ~/.nanobot-telegram/config.json
-
-# Terminal 2
nanobot gateway --config ~/.nanobot-discord/config.json
```
-### Use Cases
-
-- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc.
-- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously
-- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent)
-- **Multi-Tenant**: Serve multiple users/teams with isolated configurations
-
-### Management Scripts
-
-For production deployments, create management scripts for each instance:
+Override workspace for one-off runs when needed:
```bash
-#!/bin/bash
-# manage-telegram.sh
-
-INSTANCE_NAME="telegram"
-CONFIG_FILE="$HOME/.nanobot-telegram/config.json"
-LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log"
-
-case "$1" in
- start)
- nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 &
- echo "Started $INSTANCE_NAME instance (PID: $!)"
- ;;
- stop)
- pkill -f "nanobot gateway.*$CONFIG_FILE"
- echo "Stopped $INSTANCE_NAME instance"
- ;;
- restart)
- $0 stop
- sleep 2
- $0 start
- ;;
- status)
- pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null
- if [ $? -eq 0 ]; then
- echo "$INSTANCE_NAME instance is running"
- else
- echo "$INSTANCE_NAME instance is not running"
- fi
- ;;
- *)
- echo "Usage: $0 {start|stop|restart|status}"
- exit 1
- ;;
-esac
+nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
```
-### systemd Service (Linux)
+### Common Use Cases
-For automatic startup and crash recovery, create a systemd service for each instance:
-
-```ini
-# ~/.config/systemd/user/nanobot-telegram.service
-[Unit]
-Description=Nanobot Telegram Instance
-After=network.target
-
-[Service]
-Type=simple
-ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json
-Restart=always
-RestartSec=10
-
-[Install]
-WantedBy=default.target
-```
-
-Enable and start:
-```bash
-systemctl --user daemon-reload
-systemctl --user enable --now nanobot-telegram
-systemctl --user enable --now nanobot-discord
-```
-
-### launchd Service (macOS)
-
-Create a plist file for each instance:
-
-```xml
-
-
-
-
-
- Label
- com.nanobot.telegram
-
- ProgramArguments
-
- /path/to/nanobot
- gateway
- --config
- /Users/yourname/.nanobot-telegram/config.json
-
-
- RunAtLoad
-
-
- KeepAlive
-
-
- StandardOutPath
- /Users/yourname/.nanobot-telegram/logs/stdout.log
-
- StandardErrorPath
- /Users/yourname/.nanobot-telegram/logs/stderr.log
-
-
-```
-
-Load the service:
-```bash
-launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist
-launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist
-```
+- Run separate bots for Telegram, Discord, Feishu, and other platforms
+- Keep testing and production instances isolated
+- Use different models or providers for different teams
+- Serve multiple tenants with separate configs and runtime data
### Notes
-- Each instance must use a different port (default: 18790)
-- Instances are completely independent — no shared state or cross-talk
-- You can run different LLM models, providers, and channel configurations per instance
-- Memory, sessions, and cron jobs are fully isolated between instances
+- 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
+- `--workspace` overrides the workspace defined in the config file
+- Cron jobs and runtime media/state are derived from the config directory
## CLI Reference
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index b91bacc..f0485bd 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
-import { homedir } from 'os';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -162,7 +161,7 @@ export class WhatsAppClient {
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
try {
- const mediaDir = join(homedir(), '.nanobot', 'media');
+ const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 0187c62..2ee4f77 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -12,6 +12,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import DiscordConfig
from nanobot.utils.helpers import split_message
@@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel):
content_parts = [content] if content else []
media_paths: list[str] = []
- media_dir = Path.home() / ".nanobot" / "media"
+ media_dir = get_media_dir("discord")
for attachment in payload.get("attachments") or []:
url = attachment.get("url")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 2dcf710..a637025 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -14,6 +14,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import FeishuConfig
import importlib.util
@@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel):
(file_path, content_text) - file_path is None if download failed
"""
loop = asyncio.get_running_loop()
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("feishu")
data, filename = None, None
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 4967ac1..63cb0ca 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -38,7 +38,7 @@ except ImportError as e:
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
-from nanobot.config.loader import get_data_dir
+from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.utils.helpers import safe_filename
TYPING_NOTICE_TIMEOUT_MS = 30_000
@@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel):
return False
def _media_dir(self) -> Path:
- d = get_data_dir() / "media" / "matrix"
- d.mkdir(parents=True, exist_ok=True)
- return d
+ return get_media_dir("matrix")
@staticmethod
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index e762dfd..09e31c3 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -15,8 +15,8 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import MochatConfig
-from nanobot.utils.helpers import get_data_path
try:
import socketio
@@ -224,7 +224,7 @@ class MochatChannel(BaseChannel):
self._socket: Any = None
self._ws_connected = self._ws_ready = False
- self._state_dir = get_data_path() / "mochat"
+ self._state_dir = get_runtime_subdir("mochat")
self._cursor_path = self._state_dir / "session_cursors.json"
self._session_cursor: dict[str, int] = {}
self._cursor_save_task: asyncio.Task | None = None
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 501a3c1..ecb1440 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import TelegramConfig
from nanobot.utils.helpers import split_message
@@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel):
getattr(media_file, 'mime_type', None),
getattr(media_file, 'file_name', None),
)
- # Save to workspace/media/
- from pathlib import Path
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("telegram")
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
await file.download_to_drive(str(file_path))
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 47c9a30..da8906d 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -30,6 +30,7 @@ from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
+from nanobot.config.paths import get_workspace_path
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
@@ -99,7 +100,9 @@ def _init_prompt_session() -> None:
except Exception:
pass
- history_file = Path.home() / ".nanobot" / "history" / "cli_history"
+ from nanobot.config.paths import get_cli_history_path
+
+ history_file = get_cli_history_path()
history_file.parent.mkdir(parents=True, exist_ok=True)
_PROMPT_SESSION = PromptSession(
@@ -170,7 +173,6 @@ def onboard():
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
- from nanobot.utils.helpers import get_workspace_path
config_path = get_config_path()
@@ -271,8 +273,9 @@ def _make_provider(config: Config):
@app.command()
def gateway(
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
- config: str = typer.Option(None, "--config", "-c", help="Path to config file"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
# Set config path if provided (must be done before any imports that use get_data_dir)
@@ -288,7 +291,8 @@ def gateway(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -301,13 +305,15 @@ def gateway(
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
+ if workspace:
+ config.agents.defaults.workspace = workspace
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
- cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron_store_path = get_cron_dir() / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
@@ -476,7 +482,8 @@ def agent(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
config = load_config()
@@ -486,7 +493,7 @@ def agent(
provider = _make_provider(config)
# Create cron service for tool usage (no callback needed for CLI unless running)
- cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron_store_path = get_cron_dir() / "jobs.json"
cron = CronService(cron_store_path)
if logs:
@@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path:
import subprocess
# User's bridge location
- user_bridge = Path.home() / ".nanobot" / "bridge"
+ from nanobot.config.paths import get_bridge_install_dir
+
+ user_bridge = get_bridge_install_dir()
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
@@ -810,6 +819,7 @@ def channels_login():
import subprocess
from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_runtime_subdir
config = load_config()
bridge_dir = _get_bridge_dir()
@@ -820,6 +830,7 @@ def channels_login():
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
+ env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py
index 6c59668..e2c24f8 100644
--- a/nanobot/config/__init__.py
+++ b/nanobot/config/__init__.py
@@ -1,6 +1,30 @@
"""Configuration module for nanobot."""
from nanobot.config.loader import get_config_path, load_config
+from nanobot.config.paths import (
+ get_bridge_install_dir,
+ get_cli_history_path,
+ get_cron_dir,
+ get_data_dir,
+ get_legacy_sessions_dir,
+ get_logs_dir,
+ get_media_dir,
+ get_runtime_subdir,
+ get_workspace_path,
+)
from nanobot.config.schema import Config
-__all__ = ["Config", "load_config", "get_config_path"]
+__all__ = [
+ "Config",
+ "load_config",
+ "get_config_path",
+ "get_data_dir",
+ "get_runtime_subdir",
+ "get_media_dir",
+ "get_cron_dir",
+ "get_logs_dir",
+ "get_workspace_path",
+ "get_cli_history_path",
+ "get_bridge_install_dir",
+ "get_legacy_sessions_dir",
+]
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 4355bd3..7d309e5 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -23,14 +23,6 @@ def get_config_path() -> Path:
return Path.home() / ".nanobot" / "config.json"
-def get_data_dir() -> Path:
- """Get the nanobot data directory (derived from config path)."""
- config_path = get_config_path()
- # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/
- # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/
- return config_path.parent
-
-
def load_config(config_path: Path | None = None) -> Config:
"""
Load configuration from file or create default.
diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py
new file mode 100644
index 0000000..f4dfbd9
--- /dev/null
+++ b/nanobot/config/paths.py
@@ -0,0 +1,55 @@
+"""Runtime path helpers derived from the active config context."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from nanobot.config.loader import get_config_path
+from nanobot.utils.helpers import ensure_dir
+
+
+def get_data_dir() -> Path:
+ """Return the instance-level runtime data directory."""
+ return ensure_dir(get_config_path().parent)
+
+
+def get_runtime_subdir(name: str) -> Path:
+ """Return a named runtime subdirectory under the instance data dir."""
+ return ensure_dir(get_data_dir() / name)
+
+
+def get_media_dir(channel: str | None = None) -> Path:
+ """Return the media directory, optionally namespaced per channel."""
+ base = get_runtime_subdir("media")
+ return ensure_dir(base / channel) if channel else base
+
+
+def get_cron_dir() -> Path:
+ """Return the cron storage directory."""
+ return get_runtime_subdir("cron")
+
+
+def get_logs_dir() -> Path:
+ """Return the logs directory."""
+ return get_runtime_subdir("logs")
+
+
+def get_workspace_path(workspace: str | None = None) -> Path:
+ """Resolve and ensure the agent workspace path."""
+ path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
+ return ensure_dir(path)
+
+
+def get_cli_history_path() -> Path:
+ """Return the shared CLI history file path."""
+ return Path.home() / ".nanobot" / "history" / "cli_history"
+
+
+def get_bridge_install_dir() -> Path:
+ """Return the shared WhatsApp bridge installation directory."""
+ return Path.home() / ".nanobot" / "bridge"
+
+
+def get_legacy_sessions_dir() -> Path:
+ """Return the legacy global session directory used for migration fallback."""
+ return Path.home() / ".nanobot" / "sessions"
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index dce4b2e..f0a6484 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -9,6 +9,7 @@ from typing import Any
from loguru import logger
+from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.utils.helpers import ensure_dir, safe_filename
@@ -79,7 +80,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = ensure_dir(self.workspace / "sessions")
- self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
+ self.legacy_sessions_dir = get_legacy_sessions_dir()
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py
index 9163e38..46f02ac 100644
--- a/nanobot/utils/__init__.py
+++ b/nanobot/utils/__init__.py
@@ -1,5 +1,5 @@
"""Utility functions for nanobot."""
-from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path
+from nanobot.utils.helpers import ensure_dir
-__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"]
+__all__ = ["ensure_dir"]
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 6e8ecd5..57c60dc 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path:
return path
-def get_data_path() -> Path:
- """Get nanobot data directory (derived from config path)."""
- from nanobot.config.loader import get_data_dir
- return ensure_dir(get_data_dir())
-
-
-def get_workspace_path(workspace: str | None = None) -> Path:
- """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
- path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
- return ensure_dir(path)
-
-
def timestamp() -> str:
"""Current ISO timestamp."""
return datetime.now().isoformat()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..a276653 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model
runner = CliRunner()
+class _StopGateway(RuntimeError):
+ pass
+
+
@pytest.fixture
def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \
- patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
+ patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
if base_dir.exists():
@@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
+
+
+def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr(
+ "nanobot.config.loader.set_config_path",
+ lambda path: seen.__setitem__("config_path", path),
+ )
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr(
+ "nanobot.cli.commands.sync_workspace_templates",
+ lambda path: seen.__setitem__("workspace", path),
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["config_path"] == config_file.resolve()
+ assert seen["workspace"] == Path(config.agents.defaults.workspace)
+
+
+def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ override = tmp_path / "override-workspace"
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr(
+ "nanobot.cli.commands.sync_workspace_templates",
+ lambda path: seen.__setitem__("workspace", path),
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(
+ app,
+ ["gateway", "--config", str(config_file), "--workspace", str(override)],
+ )
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["workspace"] == override
+ assert config.workspace_path == override
+
+
+def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
+ monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
+ monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
+
+ class _StopCron:
+ def __init__(self, store_path: Path) -> None:
+ seen["cron_store"] = store_path
+ raise _StopGateway("stop")
+
+ monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py
new file mode 100644
index 0000000..473a6c8
--- /dev/null
+++ b/tests/test_config_paths.py
@@ -0,0 +1,42 @@
+from pathlib import Path
+
+from nanobot.config.paths import (
+ get_bridge_install_dir,
+ get_cli_history_path,
+ get_cron_dir,
+ get_data_dir,
+ get_legacy_sessions_dir,
+ get_logs_dir,
+ get_media_dir,
+ get_runtime_subdir,
+ get_workspace_path,
+)
+
+
+def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance-a" / "config.json"
+ monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
+
+ assert get_data_dir() == config_file.parent
+ assert get_runtime_subdir("cron") == config_file.parent / "cron"
+ assert get_cron_dir() == config_file.parent / "cron"
+ assert get_logs_dir() == config_file.parent / "logs"
+
+
+def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance-b" / "config.json"
+ monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
+
+ assert get_media_dir() == config_file.parent / "media"
+ assert get_media_dir("telegram") == config_file.parent / "media" / "telegram"
+
+
+def test_shared_and_legacy_paths_remain_global() -> None:
+ assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
+ assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
+ assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
+
+
+def test_workspace_path_is_explicitly_resolved() -> None:
+ assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
+ assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"