From c16fada8d87aeaaee1d24205ef936a28cca9155c Mon Sep 17 00:00:00 2001 From: TheMajesticMagician Date: Sun, 26 Jul 2026 18:49:51 -0600 Subject: [PATCH] Update --- .claude/settings.local.json | 12 +- .env.example | 6 + CLAUDE.md | 63 ++++++- bolt_pet/config.py | 11 ++ bolt_pet/controller.py | 68 ++++++-- bolt_pet/file_delivery.py | 54 ++++++ bolt_pet/file_ops.py | 280 ++++++++++++++++++++++++++++++ bolt_pet/server_client.py | 31 ++++ tests/test_controller_features.py | 135 ++++++++++++++ tests/test_file_delivery.py | 54 ++++++ tests/test_file_ops.py | 266 ++++++++++++++++++++++++++++ tests/test_server_client.py | 44 +++++ 12 files changed, 1007 insertions(+), 17 deletions(-) create mode 100644 bolt_pet/file_delivery.py create mode 100644 bolt_pet/file_ops.py create mode 100644 tests/test_file_delivery.py create mode 100644 tests/test_file_ops.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7ab6472..9717ffd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,17 @@ "Bash(git push *)", "Bash(git remote *)", "Bash(grep -v '^$')", - "Bash(.venv/bin/pip install *)" + "Bash(.venv/bin/pip install *)", + "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest tests/ -q)", + "Bash(.venv/bin/pytest tests/test_desk_api.py tests/test_desk_files.py tests/test_desk_voice.py tests/test_desk_status.py tests/test_desk_keys.py tests/test_desk_guild_action.py tests/test_desk_admin.py tests/test_desk_billing_auth.py -q)", + "Bash(docker inspect *)", + "Bash(python3 -m json.tool)", + "Bash(docker restart bolt *)", + "Bash(curl -s -m 5 \"http://localhost:5002/desk/health\")", + "Bash(python3 -c ' *)", + "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)", + "Bash(docker exec bolt *)", + "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)" ] } } diff --git a/.env.example b/.env.example index 1cfd165..a6968b8 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,12 @@ ELEVENLABS_VOICE_ID= #SUDO_ASKPASS_HELPER= # blank = auto-detect #SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer +# ── File delivery (optional) ──────────────────────────────────────────────── +# The server's deliver_files tool ("send me that report") queues workspace +# files on this session; the pet fetches and saves them automatically. +#RECEIVE_FILES=true +#DELIVERED_FILES_DIR=~/Downloads/Bolt + # ── Misc (optional) ────────────────────────────────────────────────────────── #COMMAND_TIMEOUT_SECONDS=30 #HEARTBEAT_INTERVAL_SECONDS=60 diff --git a/CLAUDE.md b/CLAUDE.md index 6d1e394..7fd1bb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,10 +60,10 @@ logs a missing-config message and exits its thread instead of starting. a `QWidget` directly. Also drives the periodic heartbeat (`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the server push proactive spoken announcements between user turns, and on the - same tick re-evaluates nap state and drains queued desktop notifications. - It owns the live wake-word threshold (`wake_threshold()` is passed to - `listen_for_wake_word` as a *callable* so the tray slider takes effect - mid-listen) and the conversation `history`. + same tick re-evaluates nap state, checks for delivered files, and drains + queued desktop notifications. It owns the live wake-word threshold + (`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so + the tray slider takes effect mid-listen) and the conversation `history`. - **`server_client.py`** — HTTP client for the desk API, dependency-free beyond `requests` so it's easy to mock in tests. `converse()` loops relaying server-issued shell commands (`run_local_command`, executed via @@ -71,7 +71,24 @@ logs a missing-config message and exits its thread instead of starting. `/desk/tool_result` until the server sends a final `reply` (capped at `_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as the server repo's other desk clients — commands only ever originate from - the user's own voice/click requests in their own session. + the user's own voice/click requests in their own session. `list_outbox_files` + / `download_outbox_file` hit the same `/desk/files` and `/desk/files/` + endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`. +- **`file_delivery.py`** — the filesystem half of receiving files the server + queues via its `deliver_files` tool (`ai/desk_api.py` in the main tmn-api + repo — "send me that report" during a conversation spools the matched + workspace files, zipping multiple into one, onto the session's outbox). + `controller._check_deliveries` lists `/desk/files` and downloads anything + queued — right after a conversation/notification turn (the common case) + and once per heartbeat tick for anything queued out-of-band — saving each + under `DELIVERED_FILES_DIR` (default `~/Downloads/Bolt`). Downloading a + file dequeues it server-side, so it's only ever handed out once; `save()` + never overwrites an existing download, suffixing `" (1)"`, `" (2)"`, ... on + a name collision. `sanitize_filename()` reduces a server-supplied name to + its bare filename (`Path(...).name`), which is defense-in-depth against a + delivered name that's secretly a path, since a per-user desk API key means + the name isn't always coming from someone as trusted as the owner. Toggle + off entirely with `RECEIVE_FILES=false`. - **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx` detection + `NearMissLog` for threshold tuning — see below), `stt.py` @@ -95,6 +112,35 @@ logs a missing-config message and exits its thread instead of starting. existing shell-command relay: `controller._handle_command` parses them and they never reach `subprocess`; anything else is a real shell command exactly as before. Pure parsing; the UI half is `PetWindow.apply_action`. +- **`file_ops.py`** — `filectl` pseudo-commands, checked in `_handle_command` + right after petctl and before falling through to a real shell command. + Executing arbitrary commands already worked via the shell relay + (`run_local_command` — see `server_client.py` below); what filectl adds is + a *reliable* way to do the read/write/edit/list slice of that, since + getting the model to hand-roll a shell heredoc for multi-line content full + of quotes/`$`/backticks is failure-prone — and `list` exists as its own op + (rather than relying on the model shelling out to `ls`/`dir`) because this + project is cross-platform and the model shouldn't have to guess which + listing command applies on Windows vs. Linux vs. macOS; one glob-based op + (`pattern`, default `*`; `recursive` for `rglob` instead of `glob`) covers + all three. Wire format is `filectl ` where `` is a + **single-line** compact JSON object — + `{"op": "list"|"read"|"write"|"edit", "path": ..., ...}` — not a multi-line + marker block (an earlier design): the server relays this as the argument + to the ordinary `command` tool marker, and that marker's extractor + (`ai/agents/default.py` in the main repo) only captures up to the next + newline, so anything genuinely multi-line silently got truncated no matter + how the prompt worded it. JSON sidesteps that for free — `json.dumps` + already encodes embedded newlines as the two characters `\n`, not a real + line break, so multi-line file content still fits on the one physical line + the extractor sees. `edit` requires the old text to match exactly once — + same discipline as this project's own code-editing tool — and raises + rather than guessing if it's missing or ambiguous. This doesn't expand + what the server can do to this machine (a relayed shell command could + already overwrite anything the desktop user can write — see the security + notes below); it's a safer path to the same capability. Pure parsing + (`parse`) is separated from the filesystem I/O (`execute`), matching + pet_actions.py's parse/describe split. - **`screen_context.py`** — active-window title (xprop/xdotool, Win32, osascript) appended to each utterance via `context_for()`, plus `is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes @@ -245,6 +291,13 @@ matches the trust model of the server repo's other desk clients. Keep `DESK_API_KEY` private and don't expose the desk API port to the open internet. +`file_ops.py`'s `filectl` read/write/edit pseudo-commands ride that same +relay and are bound by the same trust model — no path is off-limits beyond +normal filesystem permissions for the desktop user, exactly like a relayed +`cat`/`sed`/`rm` already isn't. They don't grant the server anything a shell +command couldn't already do; they just make the read/write/edit path +reliable instead of relying on the model getting shell quoting right. + `sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT` on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the password is collected in a desktop dialog, so commands can escalate to root diff --git a/bolt_pet/config.py b/bolt_pet/config.py index 5aaa026..f4f0618 100644 --- a/bolt_pet/config.py +++ b/bolt_pet/config.py @@ -161,6 +161,17 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "") NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60")) +# ── file delivery ──────────────────────────────────────────────────────── +# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo) +# queues workspace files on this session — e.g. "send me that report" — for +# the client to fetch via GET /desk/files. Downloading a file dequeues it +# server-side, so each one lands here exactly once. + +RECEIVE_FILES = os.environ.get("RECEIVE_FILES", "true").lower() in ("1", "true", "yes", "on") +DELIVERED_FILES_DIR = Path( + os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt") +).expanduser() + # ── conversation history ──────────────────────────────────────────────────── HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100")) diff --git a/bolt_pet/controller.py b/bolt_pet/controller.py index 2927ff5..174b405 100644 --- a/bolt_pet/controller.py +++ b/bolt_pet/controller.py @@ -20,8 +20,8 @@ from typing import Optional from PySide6.QtCore import QObject, Signal from . import ( - config, history as history_mod, notifications, pet_actions, quiet, - screen_context, server_client, speech_text, updater, + config, file_delivery, file_ops, history as history_mod, notifications, + pet_actions, quiet, screen_context, server_client, speech_text, updater, ) from .audio import barge_in, mic, stt, tts, wake_word from .state import PetState, PetStateMachine @@ -251,25 +251,41 @@ class PetController(QObject): self._state.transition(PetState.IDLE) return + self._check_deliveries() self._speak(reply) self._state.transition(PetState.IDLE) def _handle_command(self, command: str) -> str: """Server-relayed command. `petctl ...` drives the pet's body and - never reaches a shell; everything else is a real command, exactly as - before (see the security notes in the README).""" + `filectl ...` does local file read/write/edit — neither ever reaches + a shell; everything else is a real command, exactly as before (see + the security notes in the README).""" try: action = pet_actions.parse(command) except pet_actions.ActionError as exc: self.log.emit(f"petctl: {exc}") return f"[pet] {exc}" - if action is None: - return server_client.run_local_command(command) - self.log.emit(f"Pet action: {action}") - if action["action"] == "nap": - self.set_napping(bool(action["enabled"])) - self.action.emit(action) - return pet_actions.describe(action) + if action is not None: + self.log.emit(f"Pet action: {action}") + if action["action"] == "nap": + self.set_napping(bool(action["enabled"])) + self.action.emit(action) + return pet_actions.describe(action) + + try: + file_action = file_ops.parse(command) + except file_ops.FileOpError as exc: + self.log.emit(f"filectl: {exc}") + return f"[filectl] {exc}" + if file_action is not None: + self.log.emit(file_ops.describe(file_action)) + try: + return file_ops.execute(file_action) + except file_ops.FileOpError as exc: + self.log.emit(f"filectl: {exc}") + return f"[filectl] {exc}" + + return server_client.run_local_command(command) def _speak(self, text: str) -> None: self._state.transition(PetState.TALKING) @@ -409,10 +425,39 @@ class PetController(QObject): except server_client.ServerError as exc: self.log.emit(f"Couldn't forward notification: {exc}") return + self._check_deliveries() if reply.strip(): self._speak(reply) self._state.transition(PetState.IDLE) + # ── file delivery ──────────────────────────────────────────────────── + + def _check_deliveries(self) -> None: + """Download anything the server has queued via deliver_files — + called right after a conversation/notification turn (the common + case: "send me that file") and once per heartbeat for anything + queued out-of-band. Best-effort: a failure here is logged, not + raised, so it can't sour a turn that already got its spoken reply.""" + if not config.RECEIVE_FILES: + return + try: + queued = server_client.list_outbox_files() + except server_client.ServerError as exc: + self.log.emit(f"Couldn't check for delivered files: {exc}") + return + for entry in queued: + file_id = entry.get("id") + name = entry.get("name") or file_id + if not file_id: + continue + try: + data = server_client.download_outbox_file(file_id) + except server_client.ServerError as exc: + self.log.emit(f"Couldn't download {name}: {exc}") + continue + path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data) + self.log.emit(f"Received file: {path}") + # ── auto-update ────────────────────────────────────────────────────── def _maybe_update(self) -> None: @@ -468,6 +513,7 @@ class PetController(QObject): return if self._napping: return # quiet hours: still answers when spoken to, just doesn't start + self._check_deliveries() self._drain_notifications() if self._state.state != PetState.IDLE: return diff --git a/bolt_pet/file_delivery.py b/bolt_pet/file_delivery.py new file mode 100644 index 0000000..f1e9334 --- /dev/null +++ b/bolt_pet/file_delivery.py @@ -0,0 +1,54 @@ +"""Saves files the server queues via its deliver_files tool (ai/desk_api.py +in the main tmn-api repo) to a local downloads folder. + +The server side of this is already generic — any desk client can list +GET /desk/files and fetch GET /desk/files/ (see server_client. +list_outbox_files / download_outbox_file) — so this module is just the +filesystem half: turn a server-supplied display name into a safe path and +write the bytes. + +Pure filename/path logic lives here so it's testable without touching a real +mic/network; the only I/O is the final write in save(). +""" + +from __future__ import annotations + +from pathlib import Path + +_FALLBACK_NAME = "delivered_file" + + +def sanitize_filename(name: str) -> str: + """Reduce a server-supplied name to a bare filename. Defends against a + delivered name that's actually a path (../../etc, an absolute path, ...) + — Path(...).name strips every directory component, and anything that + collapses to nothing (or "." / "..") falls back to a generic name.""" + candidate = Path(str(name or "").strip()).name + if candidate in ("", ".", ".."): + return _FALLBACK_NAME + return candidate + + +def unique_path(directory: Path, name: str) -> Path: + """*name* under *directory*, suffixed " (1)", " (2)", ... if that name is + already taken — a delivered file never overwrites an earlier download.""" + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + candidate = directory / name + if not candidate.exists(): + return candidate + stem, suffix = candidate.stem, candidate.suffix + n = 1 + while True: + candidate = directory / f"{stem} ({n}){suffix}" + if not candidate.exists(): + return candidate + n += 1 + + +def save(directory: Path, name: str, data: bytes) -> Path: + """Write *data* under *directory* as *name* (sanitized + uniquified), + returning the path written.""" + path = unique_path(directory, sanitize_filename(name)) + path.write_bytes(data) + return path diff --git a/bolt_pet/file_ops.py b/bolt_pet/file_ops.py new file mode 100644 index 0000000..ed8683d --- /dev/null +++ b/bolt_pet/file_ops.py @@ -0,0 +1,280 @@ +"""Local file read/edit/write, intercepted from the server-relayed command +channel the same way pet_actions.py intercepts petctl (see server_client. +run_local_command / controller._handle_command). Whatever text the server's +"command" tool sends is just a string this repo is free to interpret before +it ever reaches subprocess — a `filectl` pseudo-command is one such +interpretation, giving the model a way to read/write/edit files on this +machine without constructing a raw shell heredoc, where quoting, `$`, +backticks, and embedded quotes make anything beyond a one-liner failure-prone. + +Executing arbitrary commands already works today (that's exactly what +run_local_command/subprocess.run does) — filectl doesn't add that ability, +it only makes the read/write/edit slice of it reliable. It also doesn't +expand what the server can already do to this machine: a relayed shell +command could already overwrite any file the desktop user can write (see the +security notes in CLAUDE.md) — filectl is a safer *path* to the same +capability, not a new capability. + +Wire format: `filectl `, where is a single-line, compact JSON +object — critically, ONE LINE. The server's "command" tool marker only +captures the argument up to the next newline (see TOOL_SPECS/ +_extract_all_tool_calls in the main repo's ai/agents/default.py — "command" +is not declared multiline), so a marker-delimited multi-line payload (this +module's first design) silently got truncated at the first line no matter +how the prompt worded it. JSON sidesteps that for free: json.dumps() already +encodes embedded newlines as the two characters "\n", not an actual line +break, so arbitrarily multi-line file content still fits on the one physical +line the extractor captures. + + filectl {"op": "list", "path": "", "pattern": "", "recursive": } + filectl {"op": "read", "path": "", "start": , "end": } + filectl {"op": "write", "path": "", "content": ""} + filectl {"op": "edit", "path": "", "old": "", "new": ""} + +"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list` +exists even though a real `ls`/`dir` shell command already works, because +this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't +have to guess which listing command applies on this machine — one glob-based +op covers all three. Must be invoked as the argument to the +ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`) +— see the pet-only paragraph in the main repo's ai/desk_api.py +_system_context for the exact instruction the model is given, including the +"keep it one line" requirement. + +Pure parsing (parse) is separated from the filesystem I/O (execute) so the +syntax is unit-testable without touching disk, matching pet_actions.py's +parse/describe split. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +_PREFIXES = ("filectl", "file") + +# Keeps a runaway read/write/list from blowing up the tool_result relay (and, +# for read/list, from flooding the model's context with a giant response). +_MAX_READ_BYTES = 200_000 +_MAX_WRITE_BYTES = 2_000_000 +_MAX_LIST_ENTRIES = 500 + +HELP = ( + 'filectl {"op": "list", "path": "", "pattern": "", "recursive": }\n' + 'filectl {"op": "read", "path": "", "start": , "end": }\n' + 'filectl {"op": "write", "path": "", "content": ""}\n' + 'filectl {"op": "edit", "path": "", "old": "", "new": ""}\n' + "Must be all on one line — this rides the single-line \"command\" marker." +) + + +class FileOpError(Exception): + """Bad filectl syntax or a filesystem error — reported back to the + server as command output, exactly like ActionError in pet_actions.py.""" + + +def is_file_command(command: str) -> bool: + stripped = (command or "").strip() + if not stripped: + return False + first_word = stripped.split(None, 1)[0] + return first_word.lower() in _PREFIXES + + +def parse(command: str) -> Optional[dict]: + """Parse a `filectl ` string into an action dict, or None if this + isn't a filectl command at all (caller should try the next handler, or + fall back to a real shell command). Raises FileOpError on a filectl + command that doesn't make sense.""" + if not is_file_command(command): + return None + stripped = command.strip() + _, _, rest = stripped.partition(" ") + rest = rest.strip() + if not rest or rest.lower() in ("help", "-h", "--help"): + return {"action": "help"} + + try: + payload = json.loads(rest) + except json.JSONDecodeError as exc: + raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc + if not isinstance(payload, dict): + raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}") + + op = str(payload.get("op") or "help").lower() + + if op == "help": + return {"action": "help"} + + if op == "list": + path = _required_str(payload, "path") + pattern = payload.get("pattern", "*") + if not isinstance(pattern, str) or not pattern: + raise FileOpError('"pattern" must be a non-empty string') + return { + "action": "list", "path": path, + "pattern": pattern, "recursive": bool(payload.get("recursive")), + } + + if op == "read": + path = _required_str(payload, "path") + return { + "action": "read", "path": path, + "start": _line_number(payload.get("start"), "start"), + "end": _line_number(payload.get("end"), "end"), + } + + if op == "write": + path = _required_str(payload, "path") + if payload.get("content") is None: + raise FileOpError('write needs "content"') + return {"action": "write", "path": path, "content": str(payload["content"])} + + if op == "edit": + path = _required_str(payload, "path") + if payload.get("old") is None or payload.get("new") is None: + raise FileOpError('edit needs "old" and "new"') + old, new = str(payload["old"]), str(payload["new"]) + if old == new: + raise FileOpError("old and new text are identical — nothing to edit") + return {"action": "edit", "path": path, "old": old, "new": new} + + raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}") + + +def _required_str(payload: dict, key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise FileOpError(f'filectl needs a non-empty "{key}"') + return value + + +def _line_number(value, label: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise FileOpError(f"{label} must be an integer line number, got {value!r}") + return value + + +def describe(action: dict) -> str: + """Text handed back to the server before execute() runs — mirrors + pet_actions.describe, used only for the log line.""" + kind = action.get("action") + if kind == "help": + return "[filectl] help" + return f"[filectl] {kind} {action.get('path', '')}" + + +def execute(action: dict) -> str: + """Actually perform a parsed filectl action, returning the text to send + back to the server as the command's output. Raises FileOpError on any + filesystem problem, same as a bad-syntax parse error.""" + kind = action.get("action") + if kind == "help": + return HELP + if kind == "list": + return _do_list(action) + if kind == "read": + return _do_read(action) + if kind == "write": + return _do_write(action) + if kind == "edit": + return _do_edit(action) + return "[filectl] ok" + + +def _resolve(path_str: str) -> Path: + return Path(path_str).expanduser() + + +def _do_list(action: dict) -> str: + path = _resolve(action["path"]) + if not path.is_dir(): + raise FileOpError(f"no such directory: {path}") + pattern = action["pattern"] + glob = path.rglob if action["recursive"] else path.glob + try: + entries = sorted(glob(pattern), key=lambda p: str(p).lower()) + except OSError as exc: + raise FileOpError(f"couldn't list {path}: {exc}") from exc + if not entries: + return f"[no entries matching {pattern!r} in {path}]" + truncated = len(entries) > _MAX_LIST_ENTRIES + lines = [] + for entry in entries[:_MAX_LIST_ENTRIES]: + rel = entry.relative_to(path) + if entry.is_dir(): + lines.append(f"{rel}/") + continue + try: + size = entry.stat().st_size + except OSError: + size = -1 + lines.append(f"{rel}\t{size}B") + if truncated: + lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"") + return "\n".join(lines) + + +def _do_read(action: dict) -> str: + path = _resolve(action["path"]) + if not path.is_file(): + raise FileOpError(f"no such file: {path}") + try: + data = path.read_bytes() + except OSError as exc: + raise FileOpError(f"couldn't read {path}: {exc}") from exc + if len(data) > _MAX_READ_BYTES: + raise FileOpError( + f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — " + 'pass "start"/"end" to read a slice instead' + ) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc + lines = text.splitlines() + start = max(1, action.get("start") or 1) + end = min(len(lines), action.get("end") or len(lines)) + if not lines: + return "[empty file]" + return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1)) + + +def _do_write(action: dict) -> str: + path = _resolve(action["path"]) + content = action["content"] + if len(content.encode("utf-8")) > _MAX_WRITE_BYTES: + raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit") + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + except OSError as exc: + raise FileOpError(f"couldn't write {path}: {exc}") from exc + return f"[filectl] wrote {len(content)} chars to {path}" + + +def _do_edit(action: dict) -> str: + path = _resolve(action["path"]) + old, new = action["old"], action["new"] + if not path.is_file(): + raise FileOpError(f"no such file: {path}") + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise FileOpError(f"couldn't read {path}: {exc}") from exc + count = text.count(old) + if count == 0: + raise FileOpError(f"didn't find that text in {path} — nothing changed") + if count > 1: + raise FileOpError( + f"that text appears {count} times in {path} — filectl edit needs a unique match; " + "include more surrounding context" + ) + try: + path.write_text(text.replace(old, new, 1), encoding="utf-8") + except OSError as exc: + raise FileOpError(f"couldn't write {path}: {exc}") from exc + return f"[filectl] edited {path}" diff --git a/bolt_pet/server_client.py b/bolt_pet/server_client.py index f0f4e77..d9b91b1 100644 --- a/bolt_pet/server_client.py +++ b/bolt_pet/server_client.py @@ -115,6 +115,37 @@ def converse( raise ServerError(str(payload.get("error") or "unknown server response")) +def list_outbox_files(timeout: float = 15.0) -> list: + """Files the server has queued for this session via its deliver_files + tool (e.g. "send me that report" during a conversation) — each entry has + id/name/size. Downloading one (download_outbox_file) dequeues it + server-side, so a file is only ever handed out once.""" + try: + response = requests.get( + f"{config.SERVER_URL}/desk/files", + params={"session_id": config.SESSION_ID}, + headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + return list(response.json().get("files") or []) + except Exception as exc: + raise ServerError(f"couldn't list delivered files: {exc}") from exc + + +def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes: + """Fetches and dequeues one file listed by list_outbox_files().""" + try: + response = requests.get( + f"{config.SERVER_URL}/desk/files/{file_id}", + params={"session_id": config.SESSION_ID}, + headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + return response.content + except Exception as exc: + raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc + + def report_status(timeout: float = 15.0) -> Optional[str]: """Heartbeat — lets the desk API attach a pending spoken announcement (proactive nudges, reminders fired since the last heartbeat) that the pet diff --git a/tests/test_controller_features.py b/tests/test_controller_features.py index 9eff467..ed7e9d7 100644 --- a/tests/test_controller_features.py +++ b/tests/test_controller_features.py @@ -5,6 +5,7 @@ the live wake threshold. Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen. """ +import json import sys from pathlib import Path @@ -79,6 +80,62 @@ def test_petctl_nap_also_flips_the_controller_state(ctrl): assert ctrl._napping is True +# ── filectl routing ────────────────────────────────────────────────────────── +# filectl's wire format is a single-line JSON envelope (see file_ops.py's +# module docstring for why) — build commands with json.dumps so the tests +# don't hardcode escaping by hand. + +def _filectl(payload: dict) -> str: + return "filectl " + json.dumps(payload) + + +def test_filectl_commands_never_reach_the_shell(monkeypatch, ctrl, tmp_path): + ran = [] + monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) + target = tmp_path / "a.txt" + + output = ctrl._handle_command(_filectl({"op": "write", "path": str(target), "content": "hello"})) + + assert ran == [] + assert target.read_text() == "hello" + assert str(target) in output + + +def test_filectl_edit_round_trips_through_the_relay(monkeypatch, ctrl, tmp_path): + monkeypatch.setattr(controller_mod.server_client, "run_local_command", + lambda cmd: (_ for _ in ()).throw(AssertionError("should not shell out"))) + target = tmp_path / "a.py" + target.write_text("x = 1\n") + + output = ctrl._handle_command( + _filectl({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}) + ) + + assert target.read_text() == "x = 2\n" + assert str(target) in output + + +def test_bad_filectl_syntax_is_reported_back_not_executed(monkeypatch, ctrl): + ran = [] + monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) + output = ctrl._handle_command("filectl {not valid json") + assert ran == [] + assert "[filectl]" in output + + +def test_filectl_execution_failure_is_reported_back_not_raised(monkeypatch, ctrl): + output = ctrl._handle_command(_filectl({"op": "read", "path": "/no/such/file.txt"})) + assert "[filectl]" in output + + +def test_ordinary_commands_still_run_locally_alongside_filectl(monkeypatch, ctrl): + ran = [] + monkeypatch.setattr(controller_mod.server_client, "run_local_command", + lambda cmd: ran.append(cmd) or "[exit 0]\n") + ctrl._handle_command("df -h /") + assert ran == ["df -h /"] + + # ── barge-in ──────────────────────────────────────────────────────────────── def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl): @@ -383,3 +440,81 @@ def test_near_misses_are_recorded_for_the_tuner(ctrl): ctrl.reset_wake_stats() assert ctrl.wake_stats()["near_misses"] == [] + + +# ── file delivery ──────────────────────────────────────────────────────────── + +def test_check_deliveries_downloads_and_saves_queued_files(monkeypatch, ctrl, tmp_path): + monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) + monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", + lambda: [{"id": "abc", "name": "report.pdf", "size": 5}]) + monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", + lambda file_id: b"hello" if file_id == "abc" else b"") + + ctrl._check_deliveries() + + assert (tmp_path / "report.pdf").read_bytes() == b"hello" + + +def test_check_deliveries_is_a_noop_when_nothing_queued(monkeypatch, ctrl, tmp_path): + monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) + monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: []) + downloaded = [] + monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", + lambda file_id: downloaded.append(file_id)) + + ctrl._check_deliveries() + + assert downloaded == [] + + +def test_check_deliveries_can_be_disabled(monkeypatch, ctrl): + monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False) + called = {"n": 0} + monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", + lambda: called.__setitem__("n", called["n"] + 1)) + + ctrl._check_deliveries() + + assert called["n"] == 0 + + +def test_check_deliveries_logs_and_continues_on_download_failure(monkeypatch, ctrl, tmp_path): + monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) + monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [ + {"id": "bad", "name": "a.txt", "size": 1}, + {"id": "good", "name": "b.txt", "size": 1}, + ]) + + def fake_download(file_id): + if file_id == "bad": + raise controller_mod.server_client.ServerError("gone") + return b"ok" + + monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", fake_download) + logs = _capture(ctrl.log) + + ctrl._check_deliveries() + + assert (tmp_path / "b.txt").read_bytes() == b"ok" + assert not (tmp_path / "a.txt").exists() + assert any("bad" in msg or "a.txt" in msg for msg in logs) + + +def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_path): + monkeypatch.setattr(controller_mod.mic, "record_utterance", + lambda *a, **k: np.zeros(10, dtype=np.int16)) + monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file") + monkeypatch.setattr(controller_mod.server_client, "converse", + lambda text, on_command=None: "it's on the way") + monkeypatch.setattr(controller_mod.tts, "speak", + lambda text, on_error=None, should_stop=None: True) + monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) + monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", + lambda: [{"id": "abc", "name": "notes.txt", "size": 2}]) + monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", + lambda file_id: b"hi") + + ctrl._handle_conversation_turn() + + assert (tmp_path / "notes.txt").read_bytes() == b"hi" diff --git a/tests/test_file_delivery.py b/tests/test_file_delivery.py new file mode 100644 index 0000000..5835afa --- /dev/null +++ b/tests/test_file_delivery.py @@ -0,0 +1,54 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from bolt_pet import file_delivery + + +def test_sanitize_filename_strips_directory_components(): + assert file_delivery.sanitize_filename("../../etc/passwd") == "passwd" + assert file_delivery.sanitize_filename("/absolute/path/report.pdf") == "report.pdf" + assert file_delivery.sanitize_filename("plain.txt") == "plain.txt" + + +def test_sanitize_filename_falls_back_on_empty_or_dots(): + assert file_delivery.sanitize_filename("") == "delivered_file" + assert file_delivery.sanitize_filename("..") == "delivered_file" + assert file_delivery.sanitize_filename(".") == "delivered_file" + assert file_delivery.sanitize_filename(None) == "delivered_file" + + +def test_unique_path_returns_the_plain_name_when_free(tmp_path): + path = file_delivery.unique_path(tmp_path, "report.pdf") + assert path == tmp_path / "report.pdf" + + +def test_unique_path_suffixes_on_collision(tmp_path): + (tmp_path / "report.pdf").write_bytes(b"existing") + path = file_delivery.unique_path(tmp_path, "report.pdf") + assert path == tmp_path / "report (1).pdf" + + (tmp_path / "report (1).pdf").write_bytes(b"also existing") + path = file_delivery.unique_path(tmp_path, "report.pdf") + assert path == tmp_path / "report (2).pdf" + + +def test_unique_path_creates_the_directory(tmp_path): + target = tmp_path / "nested" / "dir" + file_delivery.unique_path(target, "a.txt") + assert target.is_dir() + + +def test_save_writes_bytes_and_sanitizes_the_name(tmp_path): + path = file_delivery.save(tmp_path, "../sneaky/report.pdf", b"hello") + assert path == tmp_path / "report.pdf" + assert path.read_bytes() == b"hello" + + +def test_save_never_overwrites_an_existing_download(tmp_path): + first = file_delivery.save(tmp_path, "notes.txt", b"first") + second = file_delivery.save(tmp_path, "notes.txt", b"second") + assert first != second + assert first.read_bytes() == b"first" + assert second.read_bytes() == b"second" diff --git a/tests/test_file_ops.py b/tests/test_file_ops.py new file mode 100644 index 0000000..b0c0f4b --- /dev/null +++ b/tests/test_file_ops.py @@ -0,0 +1,266 @@ +"""filectl parsing + execution — the pseudo-commands the server can relay to +read/write/edit local files instead of a raw shell heredoc. + +filectl {"op": ...} is a single-line JSON envelope (not a multi-line +marker block) because it rides the "command" tool marker, which the main +repo's tool-call extractor only captures up to the next newline — see the +module docstring in bolt_pet/file_ops.py.""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from bolt_pet import file_ops + + +def _cmd(payload: dict) -> str: + return "filectl " + json.dumps(payload) + + +# ── parsing ────────────────────────────────────────────────────────────────── + +def test_non_file_commands_are_left_alone(): + assert file_ops.parse("ls -la") is None + assert file_ops.parse("systemctl restart nginx") is None + assert file_ops.parse("") is None + # "filed" must not be mistaken for the "file" prefix + assert file_ops.parse("filed --list") is None + + +def test_list_parses_defaults(): + assert file_ops.parse(_cmd({"op": "list", "path": "/tmp"})) == { + "action": "list", "path": "/tmp", "pattern": "*", "recursive": False, + } + + +def test_list_parses_pattern_and_recursive(): + assert file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": "*.py", "recursive": True})) == { + "action": "list", "path": "/tmp", "pattern": "*.py", "recursive": True, + } + + +def test_list_requires_a_path(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "list"})) + + +def test_list_rejects_empty_pattern(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": ""})) + + +def test_read_parses_path_and_optional_line_range(): + assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt"})) == { + "action": "read", "path": "/tmp/a.txt", "start": None, "end": None, + } + assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt", "start": 10, "end": 40})) == { + "action": "read", "path": "/tmp/a.txt", "start": 10, "end": 40, + } + + +def test_read_requires_a_path(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "read"})) + + +def test_read_rejects_non_numeric_line_args(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt", "start": "start"}') + + +def test_write_parses_path_and_content(): + assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": "hello\nworld"})) == { + "action": "write", "path": "/tmp/a.txt", "content": "hello\nworld", + } + + +def test_write_allows_empty_content(): + assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": ""})) == { + "action": "write", "path": "/tmp/a.txt", "content": "", + } + + +def test_write_without_content_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt"})) + + +def test_edit_parses_old_and_new(): + assert file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz"})) == { + "action": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz", + } + + +def test_edit_rejects_identical_old_and_new(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "same", "new": "same"})) + + +def test_edit_missing_fields_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt"})) + + +def test_content_with_embedded_quotes_and_shell_metacharacters_survives(): + payload = 'echo "hi $USER" `whoami` && rm -rf /' + command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": payload}) + assert "\n" not in command # stays on one line, as the command marker requires + assert file_ops.parse(command) == {"action": "write", "path": "/tmp/a.txt", "content": payload} + + +def test_multiline_content_stays_on_one_physical_line(): + content = "line one\nline two\nline three with \"quotes\" and \\backslashes\\" + command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": content}) + assert "\n" not in command + assert file_ops.parse(command)["content"] == content + + +def test_help(): + assert file_ops.parse("filectl help") == {"action": "help"} + assert file_ops.parse("filectl") == {"action": "help"} + assert file_ops.parse(_cmd({"op": "help"})) == {"action": "help"} + + +def test_invalid_json_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse("filectl {not valid json") + + +def test_non_object_json_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse("filectl [1, 2, 3]") + + +def test_unknown_op_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.parse(_cmd({"op": "frobnicate", "path": "/tmp/a.txt"})) + + +# ── execution ──────────────────────────────────────────────────────────────── + +def test_list_shows_files_and_subdirectories(tmp_path): + (tmp_path / "a.txt").write_text("hi") + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.txt").write_text("nested") + + action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)})) + output = file_ops.execute(action) + + assert "a.txt\t2B" in output + assert "sub/" in output + assert "b.txt" not in output # non-recursive: nested file not shown + + +def test_list_recursive_finds_nested_files(tmp_path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.txt").write_text("nested") + + action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "recursive": True})) + output = file_ops.execute(action) + + assert "sub/b.txt" in output or "sub\\b.txt" in output # os-dependent separator + + +def test_list_pattern_filters_entries(tmp_path): + (tmp_path / "a.py").write_text("x") + (tmp_path / "b.txt").write_text("y") + + action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "pattern": "*.py"})) + output = file_ops.execute(action) + + assert "a.py" in output + assert "b.txt" not in output + + +def test_list_empty_directory_says_so(tmp_path): + action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)})) + output = file_ops.execute(action) + assert "no entries" in output + + +def test_list_missing_directory_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.execute({"action": "list", "path": "/no/such/dir", "pattern": "*", "recursive": False}) + + +def test_list_rejects_a_file_path(tmp_path): + target = tmp_path / "a.txt" + target.write_text("hi") + with pytest.raises(file_ops.FileOpError): + file_ops.execute({"action": "list", "path": str(target), "pattern": "*", "recursive": False}) + + +def test_list_truncates_past_the_entry_cap(tmp_path, monkeypatch): + monkeypatch.setattr(file_ops, "_MAX_LIST_ENTRIES", 3) + for i in range(5): + (tmp_path / f"f{i}.txt").write_text("x") + + action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)})) + output = file_ops.execute(action) + + assert "truncated" in output + assert output.count(".txt") == 3 + + +def test_write_then_read_round_trips(tmp_path): + target = tmp_path / "notes.txt" + write_action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "line one\nline two"})) + result = file_ops.execute(write_action) + assert target.read_text() == "line one\nline two" + assert str(target) in result + + read_action = file_ops.parse(_cmd({"op": "read", "path": str(target)})) + output = file_ops.execute(read_action) + assert "line one" in output + assert "line two" in output + + +def test_read_missing_file_raises(): + with pytest.raises(file_ops.FileOpError): + file_ops.execute({"action": "read", "path": "/no/such/file.txt", "start": None, "end": None}) + + +def test_read_respects_line_range(tmp_path): + target = tmp_path / "a.txt" + target.write_text("\n".join(f"line{i}" for i in range(1, 11))) + action = file_ops.parse(_cmd({"op": "read", "path": str(target), "start": 3, "end": 5})) + output = file_ops.execute(action) + assert "line3" in output and "line5" in output + assert "line1" not in output and "line6" not in output + + +def test_edit_replaces_a_unique_match(tmp_path): + target = tmp_path / "a.py" + target.write_text("def foo():\n return 1\n") + action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "return 1", "new": "return 2"})) + file_ops.execute(action) + assert target.read_text() == "def foo():\n return 2\n" + + +def test_edit_fails_when_text_not_found(tmp_path): + target = tmp_path / "a.py" + target.write_text("def foo():\n return 1\n") + action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "nope", "new": "x"})) + with pytest.raises(file_ops.FileOpError): + file_ops.execute(action) + assert target.read_text() == "def foo():\n return 1\n" # untouched + + +def test_edit_fails_when_text_is_ambiguous(tmp_path): + target = tmp_path / "a.py" + target.write_text("x = 1\nx = 1\n") + action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"})) + with pytest.raises(file_ops.FileOpError): + file_ops.execute(action) + assert target.read_text() == "x = 1\nx = 1\n" # untouched + + +def test_write_creates_parent_directories(tmp_path): + target = tmp_path / "nested" / "dir" / "a.txt" + action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"})) + file_ops.execute(action) + assert target.read_text() == "hi" diff --git a/tests/test_server_client.py b/tests/test_server_client.py index 918e272..6f7c87c 100644 --- a/tests/test_server_client.py +++ b/tests/test_server_client.py @@ -84,3 +84,47 @@ def test_check_health_returns_parsed_json(): get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"}) result = server_client.check_health() assert result == {"ok": True, "service": "bolt-desk-api"} + + +def test_list_outbox_files_returns_the_queue(): + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response( + {"files": [{"id": "abc", "name": "report.pdf", "size": 9}]} + ) + result = server_client.list_outbox_files() + assert result == [{"id": "abc", "name": "report.pdf", "size": 9}] + args, kwargs = get.call_args + assert args[0] == "http://test-server:5002/desk/files" + assert kwargs["params"] == {"session_id": "pet-test"} + assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} + + +def test_list_outbox_files_defaults_to_empty_list(): + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response({}) + assert server_client.list_outbox_files() == [] + + +def test_list_outbox_files_raises_server_error_when_unreachable(): + with patch.object(server_client.requests, "get", side_effect=ConnectionError("no route")): + with pytest.raises(server_client.ServerError): + server_client.list_outbox_files() + + +def test_download_outbox_file_returns_raw_bytes(): + with patch.object(server_client.requests, "get") as get: + resp = _mock_response({}) + resp.content = b"%PDF fake bytes" + get.return_value = resp + result = server_client.download_outbox_file("abc") + assert result == b"%PDF fake bytes" + args, kwargs = get.call_args + assert args[0] == "http://test-server:5002/desk/files/abc" + assert kwargs["params"] == {"session_id": "pet-test"} + + +def test_download_outbox_file_raises_server_error_on_http_failure(): + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response({}, ok=False) + with pytest.raises(server_client.ServerError, match="abc"): + server_client.download_outbox_file("abc")