Compare commits
2 Commits
v0.2.1
..
c16fada8d8
| Author | SHA1 | Date | |
|---|---|---|---|
| c16fada8d8 | |||
| e9d92b0ba2 |
@@ -29,7 +29,18 @@
|
||||
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -c ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(grep -v '^$')"
|
||||
"Bash(grep -v '^$')",
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ ELEVENLABS_VOICE_ID=
|
||||
#VAD_SILENCE_END_SEC=1.2
|
||||
#VAD_MAX_UTTERANCE_SECONDS=15
|
||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
||||
|
||||
# ── Follow-up listening (optional) ──────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for your answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain
|
||||
# without you re-triggering it (0 = no cap) — a stop on runaway loops if the
|
||||
# server ends every reply with "?" and the mic keeps feeding it noise.
|
||||
#FOLLOW_UP_LISTEN=true
|
||||
#FOLLOW_UP_MAX_TURNS=3
|
||||
#FOLLOW_UP_GRACE_SECONDS=7 # longer than VAD_GRACE_SECONDS: you were asked something
|
||||
|
||||
# ── Pet window (optional) ───────────────────────────────────────────────────
|
||||
#PET_SIZE=160
|
||||
@@ -128,6 +139,25 @@ ELEVENLABS_VOICE_ID=
|
||||
#WAKE_NEAR_MISS_MARGIN=0.2
|
||||
#WAKE_NEAR_MISS_LIMIT=40
|
||||
|
||||
# ── sudo password prompts (optional) ────────────────────────────────────────
|
||||
# The pet has no terminal, so a server-relayed `sudo` would block forever on
|
||||
# a tty nobody is watching. With this on, bare `sudo` becomes `sudo -A` and
|
||||
# the password is collected in a desktop dialog you have to answer — a real
|
||||
# askpass binary if one is installed, otherwise a generated zenity/kdialog
|
||||
# wrapper in ~/.cache/bolt-pet/askpass.sh.
|
||||
# Set it to false if you'd rather Bolt never be able to ask for root: sudo
|
||||
# commands then just fail. Read the dialogs — that box is the only thing
|
||||
# between "Bolt decided to run sudo" and it running.
|
||||
#SUDO_ASKPASS_PROMPT=true
|
||||
#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
|
||||
|
||||
@@ -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/<id>`
|
||||
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 <json>` where `<json>` 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
|
||||
@@ -108,6 +154,14 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
|
||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
||||
survivors through `converse()`. Off by default — each one is a round trip.
|
||||
- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process
|
||||
with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and
|
||||
rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex
|
||||
that skips anything already carrying a flag and anything inside quotes).
|
||||
Prefers a real askpass binary and falls back to generating a
|
||||
zenity/kdialog wrapper in `~/.cache/bolt-pet/askpass.sh`. Resolution order
|
||||
is injectable (`is_executable`/`which`) so it's testable on a machine with a
|
||||
different set installed. See the security notes — the dialog is the boundary.
|
||||
- **`updater.py`** — self-update from the Gitea releases API. Polls
|
||||
`<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
||||
`bolt_pet.__version__` and moves the checkout to it with
|
||||
@@ -137,7 +191,13 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
||||
words a few symbols (`&` → "and"). `for_display()` is the looser version for
|
||||
the speech bubble — markdown syntax gone, emoji kept. Pure string logic, no
|
||||
the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides
|
||||
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
||||
trailing one counts, since a question asked in passing isn't awaiting a
|
||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
||||
wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply
|
||||
with a question can't loop forever off mic noise. Pure string logic, no
|
||||
Qt/audio imports.
|
||||
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
||||
@@ -231,6 +291,30 @@ 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
|
||||
instead of hanging on a tty the pet doesn't have. The dialog is the security
|
||||
boundary — it's the only thing between the server deciding to run `sudo` and
|
||||
it running, so the prompt is deliberately not suppressible per-command and
|
||||
those commands get their own longer timeout (`SUDO_COMMAND_TIMEOUT_SECONDS`)
|
||||
rather than being made non-interactive. Set `SUDO_ASKPASS_PROMPT=false` to
|
||||
take the capability away entirely; sudo commands then fail. Note that
|
||||
`sudo -n` / `sudo -A` / `sudo -u …` in a relayed command are never rewritten,
|
||||
so an explicit non-interactive sudo stays non-interactive.
|
||||
|
||||
**Don't run the pet as root.** It needs no privileges of its own, PortAudio
|
||||
can't reach the user's PipeWire socket from a root session (raw ALSA devices
|
||||
reject the 16 kHz capture rate — `paInvalidSampleRate`), and every relayed
|
||||
command would run unconstrained.
|
||||
|
||||
Two newer features widen what leaves this machine, both switchable in `.env`:
|
||||
`SCREEN_CONTEXT` appends the focused window's *title* to each utterance
|
||||
(titles often contain file paths, document names, or subject lines), and
|
||||
|
||||
@@ -44,6 +44,7 @@ def record_utterance(
|
||||
silence_end_sec: float = None,
|
||||
max_utterance_s: float = None,
|
||||
min_utterance_s: float = None,
|
||||
grace_s: float = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
sample_rate: int = config.SAMPLE_RATE,
|
||||
) -> Optional[np.ndarray]:
|
||||
@@ -53,18 +54,24 @@ def record_utterance(
|
||||
*should_continue* is polled each frame so a caller can cancel recording
|
||||
(e.g. the pet window was closed) without needing threading primitives
|
||||
baked into this function.
|
||||
|
||||
*grace_s* is how long to wait for speech to *begin* before giving up.
|
||||
The controller stretches it for follow-up questions, where you're being
|
||||
asked something and need a moment to think rather than having just said
|
||||
the wake word on purpose.
|
||||
"""
|
||||
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
|
||||
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
|
||||
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
|
||||
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
|
||||
grace_s = config.GRACE_SECONDS if grace_s is None else grace_s
|
||||
|
||||
frames: list[np.ndarray] = []
|
||||
started = False
|
||||
silence_frames = 0
|
||||
silence_limit = int(silence_end_sec * sample_rate / frame_len)
|
||||
max_frames = int(max_utterance_s * sample_rate / frame_len)
|
||||
grace_frames = int(4.0 * sample_rate / frame_len) # wait up to 4s for speech to begin
|
||||
grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin
|
||||
waited = 0
|
||||
|
||||
while should_continue():
|
||||
|
||||
@@ -78,8 +78,36 @@ RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
||||
SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2"))
|
||||
MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15"))
|
||||
MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
|
||||
# How long to wait for you to *start* talking before giving up on a turn.
|
||||
GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
|
||||
|
||||
# ── follow-up listening ─────────────────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for the answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# The grace period is longer than a normal turn's because you were asked
|
||||
# something and may need a beat to think. FOLLOW_UP_MAX_TURNS caps how many
|
||||
# question-and-answer rounds can chain without you re-triggering it — a stop
|
||||
# on runaway loops if the server ends every reply with a question and the mic
|
||||
# keeps feeding it noise. 0 means no cap.
|
||||
|
||||
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
|
||||
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "3"))
|
||||
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# ── sudo password prompts ───────────────────────────────────────────────────
|
||||
# The pet has no terminal, so a relayed `sudo` would block on a tty nobody is
|
||||
# watching. With this on, bare `sudo` is rewritten to `sudo -A` and the
|
||||
# password is collected in a desktop dialog (a real askpass binary if one is
|
||||
# installed, otherwise a generated zenity/kdialog wrapper). Turn it off and
|
||||
# sudo commands simply fail, which is the safer default if you'd rather Bolt
|
||||
# never be able to ask for root at all.
|
||||
SUDO_ASKPASS_PROMPT = os.environ.get("SUDO_ASKPASS_PROMPT", "true").lower() in ("1", "true", "yes", "on")
|
||||
SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-detect
|
||||
# Longer than COMMAND_TIMEOUT_SECONDS because a person has to notice the
|
||||
# dialog, read it, and type — 30s is nowhere near enough for that.
|
||||
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
|
||||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
||||
@@ -133,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"))
|
||||
|
||||
+104
-8
@@ -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
|
||||
@@ -63,6 +63,13 @@ class PetController(QObject):
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
self._last_nap_check = 0.0
|
||||
|
||||
# When a reply ends on a question the pet keeps listening for the
|
||||
# answer. _follow_ups counts how many have chained without you
|
||||
# re-triggering, so a server that ends every reply with "?" can't
|
||||
# loop forever off mic noise.
|
||||
self._pending_follow_up = False
|
||||
self._follow_ups = 0
|
||||
|
||||
self._last_update_check = 0.0
|
||||
self._update_pending = False # applied on disk, waiting for the restart
|
||||
|
||||
@@ -200,9 +207,21 @@ class PetController(QObject):
|
||||
self._near_misses.observe(score, threshold, time.time())
|
||||
|
||||
def _handle_conversation_turn(self) -> None:
|
||||
# A turn you started yourself ends any follow-up chain in progress.
|
||||
following_up, self._pending_follow_up = self._pending_follow_up, False
|
||||
if not following_up:
|
||||
self._follow_ups = 0
|
||||
|
||||
self._state.transition(PetState.LISTENING)
|
||||
pcm = mic.record_utterance(self._stream, should_continue=self._should_continue)
|
||||
pcm = mic.record_utterance(
|
||||
self._stream,
|
||||
should_continue=self._should_continue,
|
||||
# Answering a question deserves longer than saying the wake word
|
||||
# on purpose does — you were just asked something.
|
||||
grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None,
|
||||
)
|
||||
if pcm is None:
|
||||
self._follow_ups = 0 # silence ends the chain
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
@@ -232,26 +251,42 @@ 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)
|
||||
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)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
@@ -270,6 +305,9 @@ class PetController(QObject):
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
)
|
||||
# Read the scoring history *before* resetting, or the log reports the
|
||||
# blank counters instead of what actually fired.
|
||||
detail = self._barge_in_detail()
|
||||
if self._barge_in is not None:
|
||||
# Playback fed the pet's own voice into the wake model's rolling
|
||||
# window. Clear it before the idle listener starts scoring again,
|
||||
@@ -278,8 +316,36 @@ class PetController(QObject):
|
||||
if not completed:
|
||||
# You talked over it — take that as the start of the next turn
|
||||
# rather than making you say the wake word again.
|
||||
self.log.emit(f"Interrupted — listening. {self._barge_in_detail()}")
|
||||
self.log.emit(f"Interrupted — listening. {detail}")
|
||||
self._follow_ups = 0 # you're clearly engaged; start the count over
|
||||
self._talk_now.set()
|
||||
elif self._should_follow_up(text):
|
||||
self._follow_ups += 1
|
||||
cap = config.FOLLOW_UP_MAX_TURNS
|
||||
self.log.emit(
|
||||
f"Asked a question — listening for your answer "
|
||||
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
|
||||
)
|
||||
self._pending_follow_up = True
|
||||
self._talk_now.set()
|
||||
|
||||
def _should_follow_up(self, text: str) -> bool:
|
||||
"""Whether *text* leaves the pet waiting on an answer.
|
||||
|
||||
Muted is excluded because mute means "don't listen to me" — an
|
||||
automatic turn would walk straight past it. Napping isn't: quiet
|
||||
hours suppress the pet *starting* something, and a question is only
|
||||
ever asked in reply to you."""
|
||||
if not config.FOLLOW_UP_LISTEN or self._muted:
|
||||
return False
|
||||
if not speech_text.is_question(text):
|
||||
return False
|
||||
# Only worth mentioning the cap on a reply that would otherwise have
|
||||
# kept listening, or it fires on every statement the pet makes.
|
||||
if config.FOLLOW_UP_MAX_TURNS > 0 and self._follow_ups >= config.FOLLOW_UP_MAX_TURNS:
|
||||
self.log.emit("Follow-up limit reached — say the wake word to keep going.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _barge_in_detail(self) -> str:
|
||||
"""Why the interruption fired, for the log. How far into playback it
|
||||
@@ -359,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:
|
||||
@@ -418,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
|
||||
|
||||
@@ -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/<id> (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
|
||||
@@ -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 <json>`, where <json> 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": "<dir>", "pattern": "<glob>", "recursive": <bool>}
|
||||
filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
|
||||
filectl {"op": "write", "path": "<path>", "content": "<text>"}
|
||||
filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}
|
||||
|
||||
"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": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
|
||||
'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
|
||||
'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
|
||||
'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\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 <json>` 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}"
|
||||
@@ -20,7 +20,7 @@ from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
from . import config, sudo_askpass
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
@@ -42,12 +42,25 @@ def check_health(timeout: float = 10.0) -> dict:
|
||||
def run_local_command(command: str, timeout: int = None) -> str:
|
||||
"""Execute a command relayed by the server, exactly as bolt_desk.py does —
|
||||
"full desktop control" for things like "open firefox" or "how full is my
|
||||
disk". Runs as the current desktop user. See README security notes."""
|
||||
disk". Runs as the current desktop user. See README security notes.
|
||||
|
||||
`sudo` gets special handling: the pet has no terminal, so sudo would sit
|
||||
waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on,
|
||||
bare `sudo` becomes `sudo -A` and the password is collected in a desktop
|
||||
dialog you have to answer — which also gives those commands a longer
|
||||
timeout, since a human has to notice the window and type."""
|
||||
env = None
|
||||
if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command):
|
||||
helper = sudo_askpass.find_helper()
|
||||
if helper:
|
||||
env = sudo_askpass.environment(helper)
|
||||
command = sudo_askpass.add_askpass_flag(command)
|
||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()),
|
||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
@@ -102,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
|
||||
|
||||
@@ -118,6 +118,22 @@ def for_speech(text: str) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def is_question(text: str) -> bool:
|
||||
"""True if the reply *ends* by asking the user something — the cue for
|
||||
the pet to keep listening instead of making you say the wake word again.
|
||||
|
||||
Deliberately only looks at the end. A reply that asks something in
|
||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
||||
whereas one that finishes on a question mark is. The test runs on the
|
||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
||||
spoken = for_speech(text)
|
||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
||||
spoken = spoken[:-1]
|
||||
return spoken.endswith("?")
|
||||
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
"""What the speech bubble shows: markdown syntax removed (the bubble
|
||||
can't render it) but emoji and layout-ish punctuation left alone."""
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Graphical password prompts for server-relayed `sudo` commands.
|
||||
|
||||
The pet has no terminal. When the server relays something like
|
||||
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
|
||||
finds the terminal the pet was launched from, which you're not looking at),
|
||||
and the command fails with no way to answer it.
|
||||
|
||||
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
|
||||
program and reads the password from the helper's stdout instead of a tty.
|
||||
Any GUI prompt that prints what was typed works, so this module finds a real
|
||||
askpass binary if one is installed and otherwise generates a one-line wrapper
|
||||
around zenity/kdialog, which every desktop has one of.
|
||||
|
||||
Worth being clear about what this changes: the prompt is a *feature*, not
|
||||
just plumbing. Server-relayed commands already run as your desktop user (see
|
||||
server_client.run_local_command); this lets them ask to run as root, and the
|
||||
dialog is the only thing standing between "Bolt decided to run sudo" and it
|
||||
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
|
||||
|
||||
The parts that decide *what* to run are pure functions so they're tested
|
||||
without a display, a password, or a working sudo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import config
|
||||
|
||||
# Real askpass binaries, in preference order. These are purpose-built for
|
||||
# this (they grab the keyboard, hide the input, and don't leave the password
|
||||
# in a process argument), so they win over a generated wrapper.
|
||||
_KNOWN_HELPERS = (
|
||||
"/usr/bin/ssh-askpass",
|
||||
"/usr/lib/ssh/ssh-askpass",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass3",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass",
|
||||
"/usr/libexec/openssh/ssh-askpass",
|
||||
"/usr/bin/ksshaskpass",
|
||||
"/usr/bin/lxqt-openssh-askpass",
|
||||
)
|
||||
|
||||
# Dialog tools we can wrap when no askpass binary exists. Each must print the
|
||||
# typed password to stdout and nothing else.
|
||||
_WRAPPABLE = {
|
||||
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
|
||||
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
|
||||
}
|
||||
|
||||
# `sudo` at the start of the command or right after a shell separator, not
|
||||
# already carrying a flag. Deliberately conservative: a `sudo` inside a
|
||||
# quoted string or a heredoc is left alone, because rewriting it could change
|
||||
# what the command means.
|
||||
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
|
||||
|
||||
|
||||
def add_askpass_flag(command: str) -> str:
|
||||
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
|
||||
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
|
||||
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
|
||||
return _SUDO.sub(r"\1\2 -A\3", command or "")
|
||||
|
||||
|
||||
def needs_password_prompt(command: str) -> bool:
|
||||
"""True if *command* has a `sudo` that might sit waiting on a dialog.
|
||||
Used to give those commands a longer timeout — 30 seconds is fine for a
|
||||
shell command and nowhere near enough for a human to notice a window,
|
||||
read it, and type a password."""
|
||||
return bool(_SUDO.search(command or ""))
|
||||
|
||||
|
||||
def helper_script(tool_path: str) -> str:
|
||||
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
|
||||
("[sudo] password for maji:") as $1, which is worth showing — it names
|
||||
the user the password is for."""
|
||||
name = Path(tool_path).name
|
||||
body = _WRAPPABLE[name].format(tool=tool_path)
|
||||
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
|
||||
|
||||
|
||||
def _default_cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
||||
return Path(base) / "bolt-pet"
|
||||
|
||||
|
||||
def find_helper(
|
||||
configured: str = None,
|
||||
is_executable: Callable[[str], bool] = None,
|
||||
which: Callable[[str], Optional[str]] = None,
|
||||
cache_dir: Path = None,
|
||||
write: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Path to an askpass helper, or None if the desktop has nothing we can
|
||||
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
|
||||
binary, then a generated wrapper around zenity/kdialog.
|
||||
|
||||
The lookups are injectable so the resolution order is testable on a box
|
||||
with a different set of these installed than yours."""
|
||||
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
|
||||
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
|
||||
which = which or shutil.which
|
||||
|
||||
if configured:
|
||||
return configured if is_executable(configured) else None
|
||||
|
||||
for candidate in _KNOWN_HELPERS:
|
||||
if is_executable(candidate):
|
||||
return candidate
|
||||
|
||||
for tool in _WRAPPABLE:
|
||||
tool_path = which(tool)
|
||||
if not tool_path:
|
||||
continue
|
||||
if not write:
|
||||
return tool_path
|
||||
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
|
||||
return None
|
||||
|
||||
|
||||
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
|
||||
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
|
||||
isn't secret, but it's a thing that pops up a password box, so nobody
|
||||
else on the machine gets to edit it."""
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
script = cache_dir / "askpass.sh"
|
||||
source = helper_script(tool_path)
|
||||
if not script.exists() or script.read_text(encoding="utf-8") != source:
|
||||
script.write_text(source, encoding="utf-8")
|
||||
script.chmod(stat.S_IRWXU)
|
||||
return str(script)
|
||||
except Exception:
|
||||
return None # no prompt is better than a crashed command relay
|
||||
|
||||
|
||||
def environment(helper: str, base: dict = None) -> dict:
|
||||
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
|
||||
env = dict(os.environ if base is None else base)
|
||||
env["SUDO_ASKPASS"] = helper
|
||||
return env
|
||||
@@ -183,6 +183,22 @@ def current_ref(run: GitRunner) -> str:
|
||||
return output.strip()
|
||||
|
||||
|
||||
def already_at_tag(run: GitRunner, tag: str) -> bool:
|
||||
"""True if HEAD is already the commit *tag* points at.
|
||||
|
||||
Guards the loop you get when a release is cut without bumping
|
||||
__version__ in the tagged commit: the checkout succeeds (it's a no-op),
|
||||
the pet restarts, reads the same old __version__, sees the same "newer"
|
||||
tag, and does it again — a restart every check, forever."""
|
||||
code, head = run(["rev-parse", "HEAD"])
|
||||
if code != 0 or not head.strip():
|
||||
return False
|
||||
code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"])
|
||||
if code != 0 or not target.strip():
|
||||
return False # tag isn't known locally yet, so we're certainly not on it
|
||||
return head.strip() == target.strip()
|
||||
|
||||
|
||||
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
|
||||
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
|
||||
return code == 0 and bool(output.strip())
|
||||
@@ -236,6 +252,14 @@ def apply_update(
|
||||
if code != 0:
|
||||
raise UpdateError(f"git fetch failed: {output}")
|
||||
|
||||
if already_at_tag(run, tag):
|
||||
from . import __version__
|
||||
|
||||
raise UpdateError(
|
||||
f"already checked out {tag}, but __version__ still reads {__version__} — "
|
||||
f"bump it in the tagged commit, or every check re-applies the same release"
|
||||
)
|
||||
|
||||
code, output = run(["checkout", "--force", f"tags/{tag}"])
|
||||
if code != 0:
|
||||
raise UpdateError(f"git checkout {tag} failed: {output}")
|
||||
|
||||
@@ -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,8 +80,102 @@ 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):
|
||||
"""_speak resets the detector after playback so the pet's own voice
|
||||
doesn't linger in the wake model's window. Reading the stats after that
|
||||
reset reports 0.000 at frame 0 for every interruption, which is worse
|
||||
than no instrumentation — it looks like hard evidence and isn't."""
|
||||
from bolt_pet.audio import barge_in as barge_in_mod
|
||||
|
||||
class Detector(barge_in_mod.WakeWordBargeIn):
|
||||
def __init__(self):
|
||||
self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5
|
||||
self._frame_len = 1280
|
||||
self.reset_calls = 0
|
||||
|
||||
def reset(self):
|
||||
self.reset_calls += 1
|
||||
self._frames, self._peak, self._last = 0, 0.0, 0.0
|
||||
|
||||
detector = Detector()
|
||||
ctrl._barge_in = detector
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def interrupted_playback(text, on_error=None, should_stop=None):
|
||||
# What really happens: frames get scored during playback, then one
|
||||
# clears the threshold and playback aborts.
|
||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback)
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
interrupted = next(m for m in logs if "Interrupted" in m)
|
||||
assert "0.810" in interrupted and "frame 7" in interrupted
|
||||
# Once before playback (clear the window) and once after (drop the pet's
|
||||
# own voice) — the point is that the *read* happens between them.
|
||||
assert detector.reset_calls == 2
|
||||
|
||||
|
||||
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||
logs = _capture(ctrl.log)
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
@@ -99,6 +194,110 @@ def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
# ── follow-up listening ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def spoke(monkeypatch):
|
||||
"""Playback that always completes, so only the follow-up rule decides
|
||||
whether another turn is queued."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
|
||||
|
||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?")
|
||||
|
||||
assert ctrl._talk_now.is_set() # no wake word needed for the answer
|
||||
assert ctrl._pending_follow_up # and the next turn knows it's an answer
|
||||
assert any("listening for your answer" in message for message in logs)
|
||||
|
||||
|
||||
def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
||||
ctrl._speak("It's 7:15 AM on July 23, 2026.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
assert not ctrl._pending_follow_up
|
||||
|
||||
|
||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2)
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
for _ in range(2):
|
||||
ctrl._speak("Want me to keep going?")
|
||||
ctrl._talk_now.clear()
|
||||
assert ctrl._follow_ups == 2
|
||||
|
||||
ctrl._speak("Want me to keep going?")
|
||||
|
||||
assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it
|
||||
assert any("Follow-up limit reached" in message for message in logs)
|
||||
|
||||
|
||||
def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1)
|
||||
ctrl._follow_ups = 1
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
ctrl._speak("Done — the file is saved.")
|
||||
|
||||
assert not any("Follow-up limit" in message for message in logs)
|
||||
|
||||
|
||||
def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **kw: None) # you said nothing
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._follow_ups == 0
|
||||
|
||||
|
||||
def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl):
|
||||
"""You were just asked something — you get longer to think than when you
|
||||
deliberately said the wake word."""
|
||||
grace = []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **kw: grace.append(kw.get("grace_s")) or None)
|
||||
|
||||
ctrl._handle_conversation_turn() # you started this one
|
||||
ctrl._pending_follow_up = True
|
||||
ctrl._handle_conversation_turn() # this one answers a question
|
||||
|
||||
assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS]
|
||||
|
||||
|
||||
def test_muting_stops_follow_ups(spoke, ctrl):
|
||||
ctrl._muted = True
|
||||
ctrl._speak("Shall I continue?")
|
||||
assert not ctrl._talk_now.is_set() # mute means don't listen, question or not
|
||||
|
||||
|
||||
def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False)
|
||||
ctrl._speak("Shall I continue?")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: False)
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
assert ctrl._follow_ups == 0 # you're clearly engaged
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
@@ -241,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.speech_text import for_display, for_speech
|
||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
||||
|
||||
|
||||
def test_bold_markers_are_not_spoken():
|
||||
@@ -57,3 +57,23 @@ def test_blank_and_symbol_only_input():
|
||||
def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("**Done** ✅") == "Done ✅"
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
assert is_question("Ready to go? 🚀")
|
||||
assert is_question('Shall I continue?"')
|
||||
assert is_question("Want me to fix it? **")
|
||||
|
||||
|
||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
# The '?' here is inside a URL query string, which for_speech strips.
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Graphical sudo prompts: command rewriting and helper resolution.
|
||||
Pure logic — no display, no sudo, no password."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import sudo_askpass
|
||||
|
||||
|
||||
# ── rewriting ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", "sudo -A apt update"),
|
||||
("sudo apt update", "sudo -A apt update"), # spacing preserved
|
||||
("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"),
|
||||
("ls; sudo reboot", "ls; sudo -A reboot"),
|
||||
("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"),
|
||||
("sudo systemctl restart x\nsudo systemctl status x",
|
||||
"sudo -A systemctl restart x\nsudo -A systemctl status x"),
|
||||
],
|
||||
)
|
||||
def test_bare_sudo_gets_the_askpass_flag(command, expected):
|
||||
assert sudo_askpass.add_askpass_flag(command) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"sudo -n apt update", # explicitly non-interactive
|
||||
"sudo -A apt update", # already asking
|
||||
"sudo -u bob whoami", # the caller was explicit
|
||||
"ls -la", # no sudo at all
|
||||
"echo 'run sudo later'", # inside a quoted string
|
||||
"pseudo --version", # not the word sudo
|
||||
],
|
||||
)
|
||||
def test_commands_that_must_not_be_rewritten(command):
|
||||
assert sudo_askpass.add_askpass_flag(command) == command
|
||||
|
||||
|
||||
def test_blank_input():
|
||||
assert sudo_askpass.add_askpass_flag("") == ""
|
||||
assert sudo_askpass.add_askpass_flag(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", True),
|
||||
("ls && sudo reboot", True),
|
||||
("ls -la", False),
|
||||
("echo 'sudo'", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_which_commands_get_the_longer_timeout(command, expected):
|
||||
assert sudo_askpass.needs_password_prompt(command) is expected
|
||||
|
||||
|
||||
# ── helper resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_configured_helper_wins():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass",
|
||||
)
|
||||
assert found == "/opt/my-askpass"
|
||||
|
||||
|
||||
def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced():
|
||||
"""Better to have sudo fail than to quietly prompt with something the
|
||||
user didn't choose."""
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity",
|
||||
) is None
|
||||
|
||||
|
||||
def test_a_real_askpass_binary_beats_a_generated_wrapper():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass",
|
||||
which=lambda tool: "/usr/bin/zenity",
|
||||
)
|
||||
assert found == "/usr/bin/ksshaskpass"
|
||||
|
||||
|
||||
def test_falls_back_to_wrapping_a_dialog_tool(tmp_path):
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False,
|
||||
which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None,
|
||||
cache_dir=tmp_path,
|
||||
)
|
||||
script = tmp_path / "askpass.sh"
|
||||
assert found == str(script)
|
||||
assert "zenity --password" in script.read_text()
|
||||
assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box
|
||||
|
||||
|
||||
def test_no_helper_available_at_all():
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False, which=lambda tool: None,
|
||||
) is None
|
||||
|
||||
|
||||
def test_the_wrapper_passes_sudos_prompt_through():
|
||||
"""sudo hands the helper its prompt as $1 — it names the account the
|
||||
password is for, which is worth showing in the dialog."""
|
||||
script = sudo_askpass.helper_script("/usr/bin/zenity")
|
||||
assert script.startswith("#!/bin/sh")
|
||||
assert '"$1"' in script
|
||||
|
||||
|
||||
def test_environment_points_sudo_at_the_helper():
|
||||
env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"})
|
||||
assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh"
|
||||
assert env["PATH"] == "/usr/bin" # the rest of the environment survives
|
||||
+25
-2
@@ -67,12 +67,15 @@ class FakeGit:
|
||||
*failures* maps a leading-args tuple to the (code, output) it should
|
||||
return, so a test can make exactly one command fail."""
|
||||
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False):
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
||||
head="abc1234", tag_commit="def5678"):
|
||||
self.calls = []
|
||||
self._ref = ref
|
||||
self._dirty = dirty
|
||||
self._failures = failures or {}
|
||||
self._requirements_changed = requirements_changed
|
||||
self._head = head
|
||||
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
||||
|
||||
def __call__(self, args):
|
||||
self.calls.append(list(args))
|
||||
@@ -87,7 +90,7 @@ class FakeGit:
|
||||
if head == "symbolic-ref":
|
||||
return (0, self._ref) if self._ref else (1, "")
|
||||
if head == "rev-parse":
|
||||
return 0, "abc1234"
|
||||
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
||||
if head == "diff":
|
||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
||||
return 0, ""
|
||||
@@ -160,6 +163,26 @@ def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
||||
assert ["checkout", "--force", "main"] in git.calls
|
||||
|
||||
|
||||
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
||||
"""Cut a release but forget to bump __version__ in the tagged commit and
|
||||
every check would see the same "newer" tag: check out (a no-op), restart,
|
||||
read the old version, repeat — a restart loop every check interval."""
|
||||
git = FakeGit(head="same1234", tag_commit="same1234")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
||||
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: None)
|
||||
|
||||
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
||||
|
||||
|
||||
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
||||
# The failure key matches by prefix, so make it explicit that a tag we
|
||||
# can't resolve means "not there yet" rather than "already applied".
|
||||
assert updater.already_at_tag(git, "v9.9.9") is False
|
||||
|
||||
|
||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
||||
|
||||
Reference in New Issue
Block a user