Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/426.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**The demo seed scripts can no longer wipe your real data folder.** Pointed at `~/.sentient`, some of them deleted its tasks, its database or the whole folder before refusing it. Now they all refuse that folder before changing anything, and the `--theme-only` and `--config-only` shortcuts keep a demo home's tasks paused and messaging off unless `--keep-live` is passed.
1 change: 1 addition & 0 deletions desktop/scripts/seed-automations-usermodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ async def seed_dreams(app: SentientApp) -> None:


async def main() -> None:
seed_safety.check_home(HOME) # before --reset deletes the database, also with --keep-live
HOME.mkdir(parents=True, exist_ok=True)
if ARGS.reset:
for suffix in ("", "-wal", "-shm"):
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/seed-chats-devices-channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ def main() -> None:
args = ap.parse_args()
if not args.home:
ap.error("--home or SENTIENT_HOME is required")
home = Path(args.home).expanduser().resolve()
home = seed_safety.check_home(args.home) # before anything is written or wiped, also with --keep-live
if args.add:
home.mkdir(parents=True, exist_ok=True)
os.environ["SENTIENT_HOME"] = str(home)
Expand Down
1 change: 1 addition & 0 deletions desktop/scripts/seed-integrations-notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ async def suggestion_row(app: SentientApp, note: dict, payload: dict, when: str,

# ----------------------------------------------------------------------------- seed
async def seed() -> None:
seed_safety.check_home(HOME) # before the database is deleted, also with --keep-live
HOME.mkdir(parents=True, exist_ok=True)
if not KEEP_DB:
for suffix in ("", "-wal", "-shm"):
Expand Down
4 changes: 3 additions & 1 deletion desktop/scripts/seed-memory-skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,9 +628,11 @@ def main() -> None:
args = ap.parse_args()
if not args.home:
ap.error("--home or SENTIENT_HOME is required")
home = Path(args.home).expanduser().resolve()
home = seed_safety.check_home(args.home) # before anything is written or wiped, also with --keep-live
if args.theme_only:
set_theme(home, args.theme)
if not args.keep_live:
seed_safety.announce(seed_safety.quiet(home))
return
if home.exists() and any(home.iterdir()):
if not args.reset:
Expand Down
3 changes: 3 additions & 0 deletions desktop/scripts/seed-tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,13 @@ async def seed(app: SentientApp) -> dict[str, str]:


async def main() -> None:
seed_safety.check_home(paths.home()) # before anything is written or wiped, also with --keep-live
paths.ensure_layout()
cfg = configure()
if ARGS.config_only:
print(f"Updated {paths.config_file()}")
if not ARGS.keep_live:
seed_safety.announce(seed_safety.quiet(paths.home()))
Comment thread
Tanmay-Ts marked this conversation as resolved.
return
app =SentientApp(cfg, llm=OfflineProvider(), db_path=paths.db_file(), enable_background=False) # type: ignore[arg-type]
await app.start()
Expand Down
16 changes: 12 additions & 4 deletions desktop/scripts/seed_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,24 @@ def _settle_busy_tasks(db: sqlite3.Connection) -> None:
db.execute("UPDATE tasks SET status = ?, next_execution_at = NULL WHERE id = ?", (status, task_id))


def quiet(home: str | Path) -> dict:
"""Pause the tasks and turn off channel deliveries in ``home``. Returns what changed."""
def check_home(home: str | Path) -> Path:
"""``home`` resolved, or exit if it is the default data folder (the real user's). Call it before writing."""
home = Path(home).expanduser().resolve()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from sentient import paths, secrets
from sentient.config.loader import load_config, save_config
from sentient import secrets

if secrets.is_default_home(home):
raise SystemExit(f"seed_safety: {home} is the default data folder (the real one); refusing to change it.")
return home


def quiet(home: str | Path) -> dict:
"""Pause the tasks and turn off channel deliveries in ``home``. Returns what changed."""
home = check_home(home)
from sentient import paths
from sentient.config.loader import load_config, save_config

os.environ["SENTIENT_HOME"] = str(home) # paths.* read it on every call
report = {"tasks_paused": 0, "plans_stopped": 0, "runs_stopped": 0, "chats_muted": 0, "channels_off": False}
if paths.config_file().exists() or paths.db_file().exists():
Expand Down
104 changes: 104 additions & 0 deletions tests/test_keychain_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

from __future__ import annotations

import os
import sqlite3
import subprocess
import sys
import time
from pathlib import Path

Expand Down Expand Up @@ -272,3 +276,103 @@ async def test_seed_safety_keeps_planning_tasks_and_live_runs_from_calling_the_m
def test_seed_safety_refuses_the_default_home(user_home):
with pytest.raises(SystemExit):
_seed_safety().quiet(user_home / ".sentient")


@pytest.mark.parametrize("keep_live", [False, True], ids=["quiet", "keep-live"])
@pytest.mark.parametrize("command", [
["seed-memory-skills.py", "--theme", "light", "--theme-only"],
["seed-tasks.py", "--config-only"],
], ids=["theme-only", "config-only"])
async def test_seed_shortcuts_quiet_the_home_unless_kept_live(config, isolated_home, user_home, command, keep_live):
"""--theme-only and --config-only change an already seeded home, so they quiet it too (issue #426)."""
from sentient.config.loader import load_config, save_config

save_config(config)
app = await SentientApp(config, llm=FakeProvider(), enable_background=False).start()
try:
await app.tasks.repo.insert_task({"id": "demo-daily", "name": "Daily digest", "status": "active",
"enabled": True, "plan": [], "created_at": "2026-10-01T00:00:00Z",
"updated_at": "2026-10-01T00:00:00Z"})
await app.channels.store.add_chat("telegram", "100200301", "Maya Rao", deliver=True, session_id=None)
finally:
await app.stop()

script = Path(__file__).resolve().parents[1] / "desktop" / "scripts" / command[0]
argv = [sys.executable, str(script), *command[1:], *(["--keep-live"] if keep_live else [])]
done = subprocess.run(argv, capture_output=True, text=True, timeout=120, check=False)
assert done.returncode == 0, done.stderr

app = await SentientApp(load_config(), llm=FakeProvider(), enable_background=False).start()
try:
enabled = (await app.tasks.repo.get_task("demo-daily"))["enabled"]
deliver = (await app.channels.store.chat("telegram", "100200301"))["deliver"]
finally:
await app.stop()
live = (enabled, deliver, load_config().channels.enabled)
assert live == ((True, True, True) if keep_live else (False, False, False))
assert ("quiet demo home:" in done.stdout) is not keep_live


def _run_seed_script(name: str, *args: str, user: Path) -> subprocess.CompletedProcess:
"""Run a seed script with ``user`` standing in for the user's own folder, so ``~/.sentient`` is inside it."""
script = Path(__file__).resolve().parents[1] / "desktop" / "scripts" / name
env = {**os.environ, "HOME": str(user), "USERPROFILE": str(user), "SENTIENT_HOME": str(user / ".sentient")}
return subprocess.run([sys.executable, str(script), *args], capture_output=True, text=True, timeout=120,
check=False, env=env)


@pytest.mark.parametrize("keep_live", [False, True], ids=["quiet", "keep-live"])
@pytest.mark.parametrize("command", [
["seed-memory-skills.py", "--theme", "light", "--theme-only"],
["seed-tasks.py", "--config-only"],
], ids=["theme-only", "config-only"])
def test_seed_shortcuts_refuse_the_default_home_before_writing(tmp_path, command, keep_live):
"""Pointed at ~/.sentient, a shortcut exits before it changes anything there, with --keep-live too."""
user = tmp_path / "user"
default = user / ".sentient"
default.mkdir(parents=True)
(default / "config.yaml").write_text("ui:\n theme: dark\n", encoding="utf-8")

done = _run_seed_script(*command, *(["--keep-live"] if keep_live else []), user=user)
assert done.returncode != 0 and "default data folder" in done.stderr
assert (default / "config.yaml").read_text(encoding="utf-8") == "ui:\n theme: dark\n"
assert [p.name for p in default.iterdir()] == ["config.yaml"]


@pytest.mark.parametrize("keep_live", [False, True], ids=["quiet", "keep-live"])
@pytest.mark.parametrize("command", [
["seed-tasks.py"],
["seed-integrations-notifications.py"],
["seed-automations-usermodel.py", "--reset"],
["seed-memory-skills.py", "--reset"],
["seed-chats-devices-channels.py", "--reset"],
], ids=["tasks", "integrations", "automations-reset", "memory-skills-reset", "chats-reset"])
async def test_a_seed_run_refuses_the_default_home_before_wiping_it(config, isolated_home, monkeypatch, tmp_path,
command, keep_live):
"""These runs used to delete ~/.sentient's tasks and notifications, its database or the whole folder before
quiet() refused it."""
from sentient.config.loader import save_config

user = tmp_path / "user"
default = user / ".sentient"
monkeypatch.setenv("SENTIENT_HOME", str(default))
save_config(config)
app = await SentientApp(config, llm=FakeProvider(), enable_background=False).start()
try:
await app.tasks.repo.insert_task({"id": "owner-real", "name": "Renew passport", "status": "active",
"enabled": True, "plan": [], "created_at": "2026-10-01T00:00:00Z",
"updated_at": "2026-10-01T00:00:00Z"})
await app.notify("task", "Your passport renewal is due next week.", title="Renew passport")
finally:
await app.stop()
(default / ".seeded-demo").write_text("left by an earlier seed run\n", encoding="utf-8") # lets --reset wipe

done = _run_seed_script(*command, *(["--keep-live"] if keep_live else []), user=user)
assert done.returncode != 0 and "default data folder" in done.stderr
assert (default / "sentient.db").exists()
db = sqlite3.connect(default / "sentient.db")
try:
assert [row[0] for row in db.execute("SELECT id FROM tasks")] == ["owner-real"]
assert [row[0] for row in db.execute("SELECT title FROM notifications")] == ["Renew passport"]
finally:
db.close()
Loading