From a3599b97b9bdac660b0cc4ac89c77d790b7cac92 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 12 Feb 2026 19:12:38 +0800 Subject: [PATCH 01/16] fix: bug #370, support temperature configuration --- nanobot/agent/loop.py | 8 ++++++-- nanobot/cli/commands.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 46a31bd..b43e27d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -41,6 +41,7 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, + temperature: float = 0.7, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, cron_service: "CronService | None" = None, @@ -54,6 +55,7 @@ class AgentLoop: self.workspace = workspace self.model = model or provider.get_default_model() self.max_iterations = max_iterations + self.temperature = temperature self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service @@ -195,7 +197,8 @@ class AgentLoop: response = await self.provider.chat( messages=messages, tools=self.tools.get_definitions(), - model=self.model + model=self.model, + temperature=self.temperature ) # Handle tool calls @@ -305,7 +308,8 @@ class AgentLoop: response = await self.provider.chat( messages=messages, tools=self.tools.get_definitions(), - model=self.model + model=self.model, + temperature=self.temperature ) if response.has_tool_calls: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aa99d55..812fbf0 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -323,6 +323,7 @@ def gateway( provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, + temperature=config.agents.defaults.temperature, max_iterations=config.agents.defaults.max_tool_iterations, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, @@ -428,6 +429,7 @@ def agent( bus=bus, provider=provider, workspace=config.workspace_path, + temperature=config.agents.defaults.temperature, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, From dbbbecb25c0fb528090766c379265dc84faa13c9 Mon Sep 17 00:00:00 2001 From: 3927o <1624497311@qq.com> Date: Thu, 12 Feb 2026 23:57:34 +0800 Subject: [PATCH 02/16] feat: improve fallback message when max iterations reached --- nanobot/agent/loop.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a660436..4532b4c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -243,7 +243,10 @@ class AgentLoop: break if final_content is None: - final_content = "I've completed processing but have no response to give." + if iteration >= self.max_iterations: + final_content = f"Reached {self.max_iterations} iterations without completion." + else: + final_content = "I've completed processing but have no response to give." # Log response preview preview = final_content[:120] + "..." if len(final_content) > 120 else final_content From f016025f632ee54165a28e52cfae985dc3c16cd1 Mon Sep 17 00:00:00 2001 From: Luke Milby Date: Thu, 12 Feb 2026 22:20:56 -0500 Subject: [PATCH 03/16] add feature to onboarding that will ask to generate missing workspace files --- nanobot/cli/commands.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2aa5688..3ced25e 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -163,20 +163,32 @@ def onboard(): if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") - if not typer.confirm("Overwrite?"): - raise typer.Exit() - - # Create default config - config = Config() - save_config(config) - console.print(f"[green]✓[/green] Created config at {config_path}") + if typer.confirm("Overwrite?"): + # Create default config + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Created config at {config_path}") + else: + # Create default config + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Created config at {config_path}") # Create workspace workspace = get_workspace_path() - console.print(f"[green]✓[/green] Created workspace at {workspace}") + + create_templates = True + if workspace.exists(): + console.print(f"[yellow]Workspace already exists at {workspace}[/yellow]") + if not typer.confirm("Create missing default templates? (will not overwrite existing files)"): + create_templates = False + else: + workspace.mkdir(parents=True, exist_ok=True) + console.print(f"[green]✓[/green] Created workspace at {workspace}") # Create default bootstrap files - _create_workspace_templates(workspace) + if create_templates: + _create_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") From e1c359a198639eae3619c375bccb15f665c254ea Mon Sep 17 00:00:00 2001 From: Ahwei Date: Fri, 13 Feb 2026 12:29:45 +0800 Subject: [PATCH 04/16] chore: add venv/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 36dbfc2..66dbe8c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ docs/ *.pywz *.pyzz .venv/ +venv/ __pycache__/ poetry.lock .pytest_cache/ From fd7e477b188638f666882bf29872ca44e8c38a34 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 13 Feb 2026 05:37:56 +0000 Subject: [PATCH 05/16] fix(security): bind WhatsApp bridge to localhost + optional token auth --- SECURITY.md | 6 +-- bridge/src/index.ts | 3 +- bridge/src/server.ts | 79 ++++++++++++++++++++++++------------ nanobot/channels/whatsapp.py | 3 ++ nanobot/cli/commands.py | 8 +++- nanobot/config/schema.py | 1 + 6 files changed, 68 insertions(+), 32 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ac15ba4..af3448c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,8 +95,8 @@ File operations have path traversal protection, but: - Consider using a firewall to restrict outbound connections if needed **WhatsApp Bridge:** -- The bridge runs on `localhost:3001` by default -- If exposing to network, use proper authentication and TLS +- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network) +- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js - Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700) ### 6. Dependency Security @@ -224,7 +224,7 @@ If you suspect a security breach: ✅ **Secure Communication** - HTTPS for all external API calls - TLS for Telegram API -- WebSocket security for WhatsApp bridge +- WhatsApp bridge: localhost-only binding + optional token auth ## Known Limitations diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 8db63ef..e8f3db9 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -25,11 +25,12 @@ import { join } from 'path'; const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10); const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth'); +const TOKEN = process.env.BRIDGE_TOKEN || undefined; console.log('🐈 nanobot WhatsApp Bridge'); console.log('========================\n'); -const server = new BridgeServer(PORT, AUTH_DIR); +const server = new BridgeServer(PORT, AUTH_DIR, TOKEN); // Handle graceful shutdown process.on('SIGINT', async () => { diff --git a/bridge/src/server.ts b/bridge/src/server.ts index c6fd599..7d48f5e 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -1,5 +1,6 @@ /** * WebSocket server for Python-Node.js bridge communication. + * Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth. */ import { WebSocketServer, WebSocket } from 'ws'; @@ -21,12 +22,13 @@ export class BridgeServer { private wa: WhatsAppClient | null = null; private clients: Set = new Set(); - constructor(private port: number, private authDir: string) {} + constructor(private port: number, private authDir: string, private token?: string) {} async start(): Promise { - // Create WebSocket server - this.wss = new WebSocketServer({ port: this.port }); - console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`); + // Bind to localhost only — never expose to external network + this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }); + console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`); + if (this.token) console.log('🔒 Token authentication enabled'); // Initialize WhatsApp client this.wa = new WhatsAppClient({ @@ -38,35 +40,58 @@ export class BridgeServer { // Handle WebSocket connections this.wss.on('connection', (ws) => { - console.log('🔗 Python client connected'); - this.clients.add(ws); - - ws.on('message', async (data) => { - try { - const cmd = JSON.parse(data.toString()) as SendCommand; - await this.handleCommand(cmd); - ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); - } catch (error) { - console.error('Error handling command:', error); - ws.send(JSON.stringify({ type: 'error', error: String(error) })); - } - }); - - ws.on('close', () => { - console.log('🔌 Python client disconnected'); - this.clients.delete(ws); - }); - - ws.on('error', (error) => { - console.error('WebSocket error:', error); - this.clients.delete(ws); - }); + if (this.token) { + // Require auth handshake as first message + const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000); + ws.once('message', (data) => { + clearTimeout(timeout); + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'auth' && msg.token === this.token) { + console.log('🔗 Python client authenticated'); + this.setupClient(ws); + } else { + ws.close(4003, 'Invalid token'); + } + } catch { + ws.close(4003, 'Invalid auth message'); + } + }); + } else { + console.log('🔗 Python client connected'); + this.setupClient(ws); + } }); // Connect to WhatsApp await this.wa.connect(); } + private setupClient(ws: WebSocket): void { + this.clients.add(ws); + + ws.on('message', async (data) => { + try { + const cmd = JSON.parse(data.toString()) as SendCommand; + await this.handleCommand(cmd); + ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); + } catch (error) { + console.error('Error handling command:', error); + ws.send(JSON.stringify({ type: 'error', error: String(error) })); + } + }); + + ws.on('close', () => { + console.log('🔌 Python client disconnected'); + this.clients.delete(ws); + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.clients.delete(ws); + }); + } + private async handleCommand(cmd: SendCommand): Promise { if (cmd.type === 'send' && this.wa) { await this.wa.sendMessage(cmd.to, cmd.text); diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 6e00e9d..0cf2dd7 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel): try: async with websockets.connect(bridge_url) as ws: self._ws = ws + # Send auth token if configured + if self.config.bridge_token: + await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token})) self._connected = True logger.info("Connected to WhatsApp bridge") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3158d29..92c017e 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -636,14 +636,20 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess + from nanobot.config.loader import load_config + config = load_config() bridge_dir = _get_bridge_dir() console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") + env = {**os.environ} + if config.channels.whatsapp.bridge_token: + env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token + try: - subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) + subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: console.print(f"[red]Bridge failed: {e}[/red]") except FileNotFoundError: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fdf1868..ef999b7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel): """WhatsApp channel configuration.""" enabled: bool = False bridge_url: str = "ws://localhost:3001" + bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers From 202f0a3144dfa454f9de862163251a45e8f1e2ac Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 13 Feb 2026 06:17:22 +0000 Subject: [PATCH 06/16] bump: 0.1.3.post7 --- README.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f5d3e7c..cac34e0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/pyproject.toml b/pyproject.toml index b1b3c81..80e54c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post6" +version = "0.1.3.post7" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 43e2f2605b0b0654f0e8e7cdbaf8e839aa08ca33 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 13 Feb 2026 06:26:12 +0000 Subject: [PATCH 07/16] docs: update v0.1.3.post7 news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cac34e0..207df82 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! From ccf9a6c1463c1aca974327c5388c105970ced4e2 Mon Sep 17 00:00:00 2001 From: Ahwei Date: Fri, 13 Feb 2026 15:31:30 +0800 Subject: [PATCH 08/16] fix(feishu): convert markdown headings to div elements in card messages Markdown heading syntax (#) is not properly rendered in Feishu interactive cards. Convert headings to div elements with lark_md format (bold text) for proper display. - Add _HEADING_RE regex to match markdown headings (h1-h6) - Add _split_headings() method to parse and convert headings to div elements - Update _build_card_elements() to process headings before markdown content --- nanobot/channels/feishu.py | 49 ++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 23d1415..9017b40 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel): re.MULTILINE, ) + _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE) + + _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) + @staticmethod def _parse_md_table(table_text: str) -> dict | None: """Parse a markdown table into a Feishu table element.""" @@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel): } def _build_card_elements(self, content: str) -> list[dict]: - """Split content into markdown + table elements for Feishu card.""" + """Split content into div/markdown + table elements for Feishu card.""" elements, last_end = [], 0 for m in self._TABLE_RE.finditer(content): - before = content[last_end:m.start()].strip() - if before: - elements.append({"tag": "markdown", "content": before}) + before = content[last_end:m.start()] + if before.strip(): + elements.extend(self._split_headings(before)) elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) last_end = m.end() - remaining = content[last_end:].strip() + remaining = content[last_end:] + if remaining.strip(): + elements.extend(self._split_headings(remaining)) + return elements or [{"tag": "markdown", "content": content}] + + def _split_headings(self, content: str) -> list[dict]: + """Split content by headings, converting headings to div elements.""" + protected = content + code_blocks = [] + for m in self._CODE_BLOCK_RE.finditer(content): + code_blocks.append(m.group(1)) + protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1) + + elements = [] + last_end = 0 + for m in self._HEADING_RE.finditer(protected): + before = protected[last_end:m.start()].strip() + if before: + elements.append({"tag": "markdown", "content": before}) + level = len(m.group(1)) + text = m.group(2).strip() + elements.append({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**{text}**", + }, + }) + last_end = m.end() + remaining = protected[last_end:].strip() if remaining: elements.append({"tag": "markdown", "content": remaining}) + + for i, cb in enumerate(code_blocks): + for el in elements: + if el.get("tag") == "markdown": + el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb) + return elements or [{"tag": "markdown", "content": content}] async def send(self, msg: OutboundMessage) -> None: From 8a11490798e0a60335887129f1ae2f9e87d6a24d Mon Sep 17 00:00:00 2001 From: Luke Milby Date: Fri, 13 Feb 2026 08:43:49 -0500 Subject: [PATCH 09/16] updated logic for onboard function not ask for to overwrite workspace since the logic already ensures nothing will be overwritten. Added onboard command tests and removed tests from gitignore --- .gitignore | 1 - nanobot/cli/commands.py | 10 +-- tests/test_commands.py | 134 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 tests/test_commands.py diff --git a/.gitignore b/.gitignore index 36dbfc2..fd59029 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,4 @@ docs/ __pycache__/ poetry.lock .pytest_cache/ -tests/ botpy.log diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3ced25e..4e61deb 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -177,18 +177,12 @@ def onboard(): # Create workspace workspace = get_workspace_path() - create_templates = True - if workspace.exists(): - console.print(f"[yellow]Workspace already exists at {workspace}[/yellow]") - if not typer.confirm("Create missing default templates? (will not overwrite existing files)"): - create_templates = False - else: + if not workspace.exists(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") # Create default bootstrap files - if create_templates: - _create_workspace_templates(workspace) + _create_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..462973f --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,134 @@ +import os +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from nanobot.cli.commands import app + +runner = CliRunner() + + +@pytest.fixture +def mock_paths(): + """Mock configuration and workspace paths for isolation.""" + with patch("nanobot.config.loader.get_config_path") as mock_config_path, \ + patch("nanobot.config.loader.save_config") as mock_save_config, \ + patch("nanobot.utils.helpers.get_workspace_path") as mock_ws_path: + + # Create temporary paths + base_dir = Path("./test_onboard_data") + if base_dir.exists(): + shutil.rmtree(base_dir) + base_dir.mkdir() + + config_file = base_dir / "config.json" + workspace_dir = base_dir / "workspace" + + mock_config_path.return_value = config_file + mock_ws_path.return_value = workspace_dir + + # We need save_config to actually write the file for existence checks to work + def side_effect_save_config(config): + with open(config_file, "w") as f: + f.write("{}") + + mock_save_config.side_effect = side_effect_save_config + + yield config_file, workspace_dir + + # Cleanup + if base_dir.exists(): + shutil.rmtree(base_dir) + + +def test_onboard_fresh_install(mock_paths): + """Test onboarding with no existing files.""" + config_file, workspace_dir = mock_paths + + result = runner.invoke(app, ["onboard"]) + + assert result.exit_code == 0 + assert "Created config" in result.stdout + assert "Created workspace" in result.stdout + assert "nanobot is ready" in result.stdout + + assert config_file.exists() + assert workspace_dir.exists() + assert (workspace_dir / "AGENTS.md").exists() + assert (workspace_dir / "memory" / "MEMORY.md").exists() + + +def test_onboard_existing_config_no_overwrite(mock_paths): + """Test onboarding with existing config, user declines overwrite.""" + config_file, workspace_dir = mock_paths + + # Pre-create config + config_file.write_text('{"existing": true}') + + # Input "n" for overwrite prompt + result = runner.invoke(app, ["onboard"], input="n\n") + + assert result.exit_code == 0 + assert "Config already exists" in result.stdout + + # Verify config was NOT changed + assert '{"existing": true}' in config_file.read_text() + + # Verify workspace was still created + assert "Created workspace" in result.stdout + assert workspace_dir.exists() + assert (workspace_dir / "AGENTS.md").exists() + + +def test_onboard_existing_config_overwrite(mock_paths): + """Test onboarding with existing config, user checks overwrite.""" + config_file, workspace_dir = mock_paths + + # Pre-create config + config_file.write_text('{"existing": true}') + + # Input "y" for overwrite prompt + result = runner.invoke(app, ["onboard"], input="y\n") + + assert result.exit_code == 0 + assert "Config already exists" in result.stdout + assert "Created config" in result.stdout + + # Verify config WAS changed (our mock writes "{}") + test_content = config_file.read_text() + assert test_content == "{}" or test_content == "" + + assert workspace_dir.exists() + + +def test_onboard_existing_workspace_safe_create(mock_paths): + """Test onboarding with existing workspace safely creates templates without prompting.""" + config_file, workspace_dir = mock_paths + + # Pre-create workspace + workspace_dir.mkdir(parents=True) + + # Scenario: Config exists (keep it), Workspace exists (add templates automatically) + config_file.write_text("{}") + + inputs = "n\n" # No overwrite config + result = runner.invoke(app, ["onboard"], input=inputs) + + assert result.exit_code == 0 + # Workspace exists message + # Depending on implementation, it might say "Workspace already exists" or just proceed. + # Code in commands.py Line 180: if not workspace.exists(): ... + # It does NOT print "Workspace already exists" if it exists. + # It only prints "Created workspace" if it created it. + + assert "Created workspace" not in result.stdout + + # Should NOT prompt for templates + assert "Create missing default templates?" not in result.stdout + + # But SHOULD create them (since _create_workspace_templates is called unconditionally) + assert "Created AGENTS.md" in result.stdout + assert (workspace_dir / "AGENTS.md").exists() From bd55bf527837fbdea31449b98e9a85150e5c69ea Mon Sep 17 00:00:00 2001 From: Luke Milby Date: Fri, 13 Feb 2026 08:56:37 -0500 Subject: [PATCH 10/16] cleaned up logic for onboarding --- nanobot/cli/commands.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d776871..e48865f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -163,12 +163,11 @@ def onboard(): if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") - if typer.confirm("Overwrite?"): - # Create default config - config = Config() - save_config(config) - console.print(f"[green]✓[/green] Created config at {config_path}") - else: + if not typer.confirm("Overwrite?"): + console.print("[dim]Skipping config creation[/dim]") + config_path = None # Sentinel to skip creation + + if config_path: # Create default config config = Config() save_config(config) From b76cf05c3af806624424002905c36f80a0d190a9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 13 Feb 2026 16:05:00 +0000 Subject: [PATCH 11/16] feat: add custom provider and non-destructive onboard --- nanobot/cli/commands.py | 18 +++++++++--------- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 92c017e..e540b27 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -155,21 +155,21 @@ def main( @app.command() def onboard(): """Initialize nanobot configuration and workspace.""" - from nanobot.config.loader import get_config_path, save_config + 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() if config_path.exists(): - console.print(f"[yellow]Config already exists at {config_path}[/yellow]") - if not typer.confirm("Overwrite?"): - raise typer.Exit() - - # Create default config - config = Config() - save_config(config) - console.print(f"[green]✓[/green] Created config at {config_path}") + # Load existing config — Pydantic fills in defaults for any new fields + config = load_config() + save_config(config) + console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") + else: + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Created config at {config_path}") # Create workspace workspace = get_workspace_path() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ef999b7..60bbc69 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -179,6 +179,7 @@ class ProviderConfig(BaseModel): class ProvidersConfig(BaseModel): """Configuration for LLM providers.""" + custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index fdd036e..b9071a0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -62,6 +62,20 @@ class ProviderSpec: PROVIDERS: tuple[ProviderSpec, ...] = ( + # === Custom (user-provided OpenAI-compatible endpoint) ================= + # No auto-detection — only activates when user explicitly configures "custom". + + ProviderSpec( + name="custom", + keywords=(), + env_key="OPENAI_API_KEY", + display_name="Custom", + litellm_prefix="openai", + skip_prefixes=("openai/",), + is_gateway=True, + strip_model_prefix=True, + ), + # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. From 12540ba8cba90f98cca9cc254fba031be40b2534 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 14 Feb 2026 00:58:43 +0000 Subject: [PATCH 12/16] feat: improve onboard with merge-or-overwrite prompt --- README.md | 2 +- nanobot/cli/commands.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 207df82..b5fdffa 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,583 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 18c23b1..b8d58df 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -162,13 +162,19 @@ def onboard(): config_path = get_config_path() if config_path.exists(): - # Load existing config — Pydantic fills in defaults for any new fields - config = load_config() - save_config(config) - console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") + console.print(f"[yellow]Config already exists at {config_path}[/yellow]") + console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") + console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") + if typer.confirm("Overwrite?"): + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Config reset to defaults at {config_path}") + else: + config = load_config() + save_config(config) + console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") else: - config = Config() - save_config(config) + save_config(Config()) console.print(f"[green]✓[/green] Created config at {config_path}") # Create workspace From 3b580fd6c8ecb8d7f58740749bc3e3328fbe729c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 14 Feb 2026 01:02:58 +0000 Subject: [PATCH 13/16] tests: update test_commands.py --- tests/test_commands.py | 100 ++++++++++++----------------------------- 1 file changed, 29 insertions(+), 71 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 462973f..f5495fd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,7 +1,6 @@ -import os import shutil from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from typer.testing import CliRunner @@ -13,122 +12,81 @@ runner = CliRunner() @pytest.fixture def mock_paths(): - """Mock configuration and workspace paths for isolation.""" - with patch("nanobot.config.loader.get_config_path") as mock_config_path, \ - patch("nanobot.config.loader.save_config") as mock_save_config, \ - patch("nanobot.utils.helpers.get_workspace_path") as mock_ws_path: - - # Create temporary 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: + base_dir = Path("./test_onboard_data") if base_dir.exists(): shutil.rmtree(base_dir) base_dir.mkdir() - + config_file = base_dir / "config.json" workspace_dir = base_dir / "workspace" - - mock_config_path.return_value = config_file - mock_ws_path.return_value = workspace_dir - - # We need save_config to actually write the file for existence checks to work - def side_effect_save_config(config): - with open(config_file, "w") as f: - f.write("{}") - mock_save_config.side_effect = side_effect_save_config - + mock_cp.return_value = config_file + mock_ws.return_value = workspace_dir + mock_sc.side_effect = lambda config: config_file.write_text("{}") + yield config_file, workspace_dir - - # Cleanup + if base_dir.exists(): shutil.rmtree(base_dir) def test_onboard_fresh_install(mock_paths): - """Test onboarding with no existing files.""" + """No existing config — should create from scratch.""" config_file, workspace_dir = mock_paths - + result = runner.invoke(app, ["onboard"]) - + assert result.exit_code == 0 assert "Created config" in result.stdout assert "Created workspace" in result.stdout assert "nanobot is ready" in result.stdout - assert config_file.exists() - assert workspace_dir.exists() assert (workspace_dir / "AGENTS.md").exists() assert (workspace_dir / "memory" / "MEMORY.md").exists() -def test_onboard_existing_config_no_overwrite(mock_paths): - """Test onboarding with existing config, user declines overwrite.""" +def test_onboard_existing_config_refresh(mock_paths): + """Config exists, user declines overwrite — should refresh (load-merge-save).""" config_file, workspace_dir = mock_paths - - # Pre-create config config_file.write_text('{"existing": true}') - - # Input "n" for overwrite prompt + result = runner.invoke(app, ["onboard"], input="n\n") - + assert result.exit_code == 0 assert "Config already exists" in result.stdout - - # Verify config was NOT changed - assert '{"existing": true}' in config_file.read_text() - - # Verify workspace was still created - assert "Created workspace" in result.stdout + assert "existing values preserved" in result.stdout assert workspace_dir.exists() assert (workspace_dir / "AGENTS.md").exists() def test_onboard_existing_config_overwrite(mock_paths): - """Test onboarding with existing config, user checks overwrite.""" + """Config exists, user confirms overwrite — should reset to defaults.""" config_file, workspace_dir = mock_paths - - # Pre-create config config_file.write_text('{"existing": true}') - - # Input "y" for overwrite prompt + result = runner.invoke(app, ["onboard"], input="y\n") - + assert result.exit_code == 0 assert "Config already exists" in result.stdout - assert "Created config" in result.stdout - - # Verify config WAS changed (our mock writes "{}") - test_content = config_file.read_text() - assert test_content == "{}" or test_content == "" - + assert "Config reset to defaults" in result.stdout assert workspace_dir.exists() def test_onboard_existing_workspace_safe_create(mock_paths): - """Test onboarding with existing workspace safely creates templates without prompting.""" + """Workspace exists — should not recreate, but still add missing templates.""" config_file, workspace_dir = mock_paths - - # Pre-create workspace workspace_dir.mkdir(parents=True) - - # Scenario: Config exists (keep it), Workspace exists (add templates automatically) config_file.write_text("{}") - - inputs = "n\n" # No overwrite config - result = runner.invoke(app, ["onboard"], input=inputs) - + + result = runner.invoke(app, ["onboard"], input="n\n") + assert result.exit_code == 0 - # Workspace exists message - # Depending on implementation, it might say "Workspace already exists" or just proceed. - # Code in commands.py Line 180: if not workspace.exists(): ... - # It does NOT print "Workspace already exists" if it exists. - # It only prints "Created workspace" if it created it. - assert "Created workspace" not in result.stdout - - # Should NOT prompt for templates - assert "Create missing default templates?" not in result.stdout - - # But SHOULD create them (since _create_workspace_templates is called unconditionally) assert "Created AGENTS.md" in result.stdout assert (workspace_dir / "AGENTS.md").exists() From d6d73c8167e09d34fe819db1d9f571b0fefa63bf Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 14 Feb 2026 01:03:16 +0000 Subject: [PATCH 14/16] docs: update .gitignore to remove tests --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0f26d84..d7b930d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log +tests/ From 2f2c55f921b5d207f2eb03c72f4051db18054a72 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 14 Feb 2026 01:13:49 +0000 Subject: [PATCH 15/16] fix: add missing comma and type annotation for temperature param --- nanobot/agent/loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 775ede6..22e3315 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -42,7 +42,7 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, - temperature = 0.7 + temperature: float = 0.7, memory_window: int = 50, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, From 59d5e3cc4f113abaa22de45056b39d4bce0220b3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 14 Feb 2026 01:14:47 +0000 Subject: [PATCH 16/16] docs: update line count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5fdffa..aeb6bfc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,583 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,587 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News