Skip to content

Commit da0a145

Browse files
committed
fix(#29): harden bot lifecycle, persona-correct scheduled jobs, redact tokens
Adversarial review fixes: - main: one bad/duplicate persona token no longer aborts startup — each bot starts independently; tokens are deduped; partial-failure tears down already -started bots so none are left orphaned polling. Shutdown stops every bot independently (one failure can't strand the rest). - scheduler/agent: a telegram:<persona> job is generated AS that persona via a new process(persona_name=…) override, keeping the 'system' execution mode (auto-approved writes, no memory/reflection) — fixes jobs being written in the default identity. - admin: persona bot_token is redacted on read (like the global token) and not leaked via the raw markdown view. - telegram: persona bots skip topic auto-bind (rung 0 ignores it) and the global browser-status mirror (no cross-bot progress spam).
1 parent 879b9ff commit da0a145

7 files changed

Lines changed: 156 additions & 64 deletions

File tree

api/admin.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,21 @@ class SkillUpsertIn(BaseModel):
481481
content: str
482482

483483

484+
def _persona_public(persona) -> dict:
485+
"""Persona as JSON for read-only APIs, with the bot token redacted (#29).
486+
487+
Mirrors how the global Telegram token is redacted in config reads — the token
488+
is a secret and must not leave the server in cleartext (exports, list views).
489+
"""
490+
from dataclasses import asdict, replace
491+
492+
from core.config_store import _redact
493+
from core.personae import to_markdown
494+
495+
safe = replace(persona, bot_token=_redact(persona.bot_token))
496+
return {**asdict(safe), "markdown": to_markdown(safe)}
497+
498+
484499
class PersonaUpsertIn(BaseModel):
485500
name: str
486501
agent_name: str = ""
@@ -2271,30 +2286,22 @@ async def _personae_partial() -> HTMLResponse:
22712286

22722287
@app.get("/personae", dependencies=[Depends(auth)])
22732288
async def list_personae() -> dict:
2274-
from dataclasses import asdict
2275-
2276-
from core.personae import to_markdown
2277-
22782289
store = await _persona_store_from_config(config_store)
22792290
personae = await store.list_personae()
22802291
active = (await config_store.get("agent.active_persona") or "").strip()
22812292
return {
22822293
"count": len(personae),
22832294
"active": active,
2284-
"personae": [{**asdict(p), "markdown": to_markdown(p)} for p in personae],
2295+
"personae": [_persona_public(p) for p in personae],
22852296
}
22862297

22872298
@app.get("/personae/{name}", dependencies=[Depends(auth)])
22882299
async def get_persona(name: str) -> dict:
2289-
from dataclasses import asdict
2290-
2291-
from core.personae import to_markdown
2292-
22932300
store = await _persona_store_from_config(config_store)
22942301
persona = await store.get(name)
22952302
if not persona:
22962303
raise HTTPException(404, f"Persona not found: {name}")
2297-
return {**asdict(persona), "markdown": to_markdown(persona)}
2304+
return _persona_public(persona)
22982305

22992306
@app.post("/personae", dependencies=[Depends(auth)])
23002307
async def upsert_persona(body: PersonaUpsertIn) -> HTMLResponse:

channels/telegram.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ def __init__(
5757
self.app.add_handler(MessageHandler(filters.TEXT, self._on_text))
5858
self.app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, self._on_voice))
5959
self.app.add_handler(MessageHandler(filters.PHOTO | filters.Document.IMAGE, self._on_photo))
60-
if config.topics_enabled:
60+
# Topic→persona auto-bind only makes sense on the default bot: a persona
61+
# bot resolves straight to its own persona (rung 0), so a per-topic binding
62+
# would be ignored. Topic *folding* (history isolation) still applies below.
63+
if config.topics_enabled and channel_name == "telegram":
6164
self.app.add_handler(
6265
MessageHandler(
6366
filters.StatusUpdate.FORUM_TOPIC_CREATED
@@ -378,6 +381,13 @@ async def _progress(self, chat_id: int | str):
378381
poll it and edit a single Telegram message in place (the chat equivalent
379382
of the REPL's self-updating spinner line). No-op when nothing is running.
380383
"""
384+
# ponytail: the explore status file is a single global singleton, so only
385+
# the default bot mirrors it — otherwise a run triggered via one persona-bot
386+
# would bubble into every other bot's chat (#29). Per-run scoping (a status
387+
# path keyed by channel/profile) belongs in the browser tool — follow-up.
388+
if self.channel_name != "telegram":
389+
yield
390+
return
381391
status = Path("/app/data" if Path("/app/data").exists() else "data")
382392
status = status / "browser" / "last" / "explore.status"
383393
cid, kw = self._route(chat_id) # split a folded "<chat>:<thread>" topic id

core/agent.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,19 @@ async def process(
363363
user_id: str,
364364
attachments: list[Attachment] | None = None,
365365
chat_id: str = "",
366+
persona_name: str | None = None,
366367
) -> AgentResponse:
367368
"""Process an incoming message through the LLM with tool-use loop.
368369
369370
``chat_id`` distinguishes different chats for the same user (e.g.
370371
a private Telegram chat vs. a group chat). Each unique
371372
(channel, user_id, chat_id) triple gets its own conversation history,
372373
preventing context leakage across chats.
374+
375+
``persona_name`` forces the identity instead of resolving it from the
376+
channel/binding ladder — used by the scheduler so a ``telegram:<persona>``
377+
job is generated *as* that persona while keeping the ``system`` execution
378+
mode (auto-approved writes, no memory/reflection) (#29).
373379
"""
374380

375381
# Handle /new (alias /clear) command — clear conversational context.
@@ -397,8 +403,12 @@ async def process(
397403
preamble = self._turn_preamble(decomposed_goal)
398404

399405
# Resolve the active persona (its identity, skills + tool scope) — a
400-
# per-chat binding wins over the globally selected persona (#14).
401-
persona = await self._resolve_persona(channel, user_id, chat_id)
406+
# per-chat binding wins over the globally selected persona (#14). An
407+
# explicit override (scheduler) skips the ladder (#29).
408+
if persona_name:
409+
persona = await self._load_persona(persona_name)
410+
else:
411+
persona = await self._resolve_persona(channel, user_id, chat_id)
402412
tools = scoped_tools(persona)
403413
if self.secret_store is None:
404414
tools = [t for t in tools if t["name"] not in ("list_secrets", "request_secret")]

core/main.py

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -84,52 +84,87 @@ async def _start_agent(config_store: ConfigStore):
8484
)
8585
agent.voice = voice
8686

87-
# -- Telegram --
88-
async def _start_tg(tg: TelegramChannel, name: str) -> None:
89-
agent.channels[name] = tg
90-
log.info("Starting Telegram bot (%s)…", name)
91-
await tg.app.initialize()
92-
await tg.app.start()
93-
if tg.app.updater is not None:
94-
await tg.app.updater.start_polling()
95-
96-
tg_global = config.channels.telegram
97-
if tg_global.enabled and tg_global.bot_token:
98-
await _start_tg(TelegramChannel(tg_global, agent, voice=voice), "telegram")
99-
100-
# -- Per-persona Telegram bots (#29): each persona with its own token is its
101-
# own contact. Channel name "telegram:<persona>" silos history and resolves
102-
# straight to that persona. ACL inherits the global allowlist when unset.
103-
for persona in await agent.personae.list_personae():
104-
token = (persona.bot_token or "").strip()
105-
if not token or token == tg_global.bot_token:
106-
continue # no token, or shares the default bot's token — skip
107-
pconf = TelegramConfig(
108-
enabled=True,
109-
bot_token=token,
110-
allowed_user_ids=persona.allowed_user_ids or tg_global.allowed_user_ids,
111-
topics_enabled=tg_global.topics_enabled,
112-
)
113-
await _start_tg(
114-
TelegramChannel(pconf, agent, voice=voice, channel_name=f"telegram:{persona.name}"),
115-
f"telegram:{persona.name}",
116-
)
87+
# -- Telegram: the default bot plus one bot per persona that carries a token (#29).
88+
# A single bad/revoked token must never abort the others, WhatsApp, or the
89+
# scheduler — each bot is brought up independently and failures are isolated.
90+
async def _start_tg(conf, name: str, channel_name: str = "telegram") -> None:
91+
try:
92+
tg = TelegramChannel(conf, agent, voice=voice, channel_name=channel_name)
93+
await tg.app.initialize()
94+
await tg.app.start()
95+
if tg.app.updater is not None:
96+
await tg.app.updater.start_polling()
97+
agent.channels[name] = tg # registered only once it is actually polling
98+
log.info("Telegram bot started (%s)", name)
99+
except Exception:
100+
log.exception("Failed to start Telegram bot %s — skipping", name)
101+
102+
try:
103+
tg_global = config.channels.telegram
104+
seen_tokens: set[str] = set()
105+
if tg_global.enabled and tg_global.bot_token:
106+
seen_tokens.add(tg_global.bot_token)
107+
await _start_tg(tg_global, "telegram")
108+
109+
for persona in await agent.personae.list_personae():
110+
token = (persona.bot_token or "").strip()
111+
if not token:
112+
continue # no own bot — reachable only via the default bot
113+
if token in seen_tokens:
114+
log.warning(
115+
"Persona %s shares a bot token with another bot — skipping its bot "
116+
"(one token can only be polled once)",
117+
persona.name,
118+
)
119+
continue
120+
seen_tokens.add(token)
121+
pconf = TelegramConfig(
122+
enabled=True,
123+
bot_token=token,
124+
allowed_user_ids=persona.allowed_user_ids or tg_global.allowed_user_ids,
125+
topics_enabled=tg_global.topics_enabled,
126+
)
127+
await _start_tg(pconf, f"telegram:{persona.name}", f"telegram:{persona.name}")
128+
129+
# -- WhatsApp --
130+
if config.channels.whatsapp.enabled:
131+
from core.wacli import WacliManager
132+
133+
wacli = WacliManager()
134+
wa = WhatsAppChannel(config.channels.whatsapp, agent, wacli=wacli)
135+
agent.channels["whatsapp"] = wa
136+
log.info("WhatsApp channel enabled (wacli)")
137+
138+
# -- Scheduler --
139+
await agent.scheduler.load_jobs()
140+
agent.scheduler.start()
141+
log.info("Scheduler started with %d jobs", len(agent.scheduler.scheduler.get_jobs()))
142+
except Exception:
143+
# Bring-up failed after some bots were already polling — stop them so we
144+
# don't leak orphaned pollers (which would 409 on the next start).
145+
await _stop_telegram_bots(agent)
146+
raise
117147

118-
# -- WhatsApp --
119-
if config.channels.whatsapp.enabled:
120-
from core.wacli import WacliManager
148+
return agent
121149

122-
wacli = WacliManager()
123-
wa = WhatsAppChannel(config.channels.whatsapp, agent, wacli=wacli)
124-
agent.channels["whatsapp"] = wa
125-
log.info("WhatsApp channel enabled (wacli)")
126150

127-
# -- Scheduler --
128-
await agent.scheduler.load_jobs()
129-
agent.scheduler.start()
130-
log.info("Scheduler started with %d jobs", len(agent.scheduler.scheduler.get_jobs()))
151+
async def _stop_telegram_bots(agent) -> None:
152+
"""Stop and deregister the default bot and every per-persona bot (#29).
131153
132-
return agent
154+
Each bot is torn down independently: one that fails to stop must not strand
155+
the rest still polling (which would 409 on the next start).
156+
"""
157+
for name, ch in list(agent.channels.items()):
158+
if name != "telegram" and not name.startswith("telegram:"):
159+
continue
160+
try:
161+
if ch.app.updater is not None:
162+
await ch.app.updater.stop()
163+
await ch.app.stop()
164+
await ch.app.shutdown()
165+
except Exception:
166+
log.exception("Error stopping Telegram bot %s", name)
167+
agent.channels.pop(name, None)
133168

134169

135170
async def _stop_agent(agent) -> None:
@@ -140,12 +175,7 @@ async def _stop_agent(agent) -> None:
140175

141176
set_agent_context(None)
142177

143-
# Stop the default bot and every per-persona bot ("telegram:<persona>", #29).
144-
for name, ch in list(agent.channels.items()):
145-
if name == "telegram" or name.startswith("telegram:"):
146-
await ch.app.updater.stop()
147-
await ch.app.stop()
148-
await ch.app.shutdown()
178+
await _stop_telegram_bots(agent)
149179

150180

151181
# ---------------------------------------------------------------------------

core/scheduler.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,17 @@ async def run_agent_task(
6464
)
6565

6666
log.info("Scheduler running agent task: %s", task[:100])
67+
# A "telegram:<persona>" job is generated AS that persona (#29) so the bot
68+
# that delivers it also writes it — while keeping the "system" execution mode
69+
# (auto-approved writes, no memory/reflection). Bare channels keep the default.
70+
gen_persona = channel.split(":", 1)[1] if channel.startswith("telegram:") else None
6771
try:
6872
response = await agent.process(
6973
message=task,
7074
channel="system",
7175
user_id="scheduler",
7276
chat_id="scheduler",
77+
persona_name=gen_persona,
7378
)
7479

7580
# Deliver the response to the target channel

tests/test_personae_admin.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ def test_persona_raw_markdown_upsert(tmp_path) -> None:
107107

108108

109109
def test_persona_bot_fields_persist(tmp_path) -> None:
110-
# Per-persona Telegram bot (#29): token + ACL survive the round-trip and the
111-
# ACL is parsed from a comma-separated string into ints.
110+
# Per-persona Telegram bot (#29): token + ACL survive the round-trip; the ACL
111+
# is parsed from a comma-separated string into ints; the token is REDACTED on
112+
# read (a secret, like the global Telegram token) but stored in full.
112113
client, _ = _client(tmp_path)
113114
r = client.post(
114115
"/personae",
@@ -122,7 +123,11 @@ def test_persona_bot_fields_persist(tmp_path) -> None:
122123
)
123124
assert r.status_code == 200
124125
got = client.get("/personae/coach", headers=AUTH).json()
125-
assert got["bot_token"] == "123456:ABC-DEF"
126+
# Redacted on read, but head/tail prove the full value reached storage.
127+
assert "***" in got["bot_token"]
128+
assert got["bot_token"].startswith("1234") and got["bot_token"].endswith("-DEF")
129+
assert got["bot_token"] != "123456:ABC-DEF"
130+
assert "123456:ABC-DEF" not in got["markdown"] # not leaked via the raw view either
126131
assert got["allowed_user_ids"] == [111, 222]
127132

128133

tests/test_scheduler.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ async def test_run_agent_task_sends_to_owner() -> None:
9393
channel.send.assert_awaited_once_with(123, "done")
9494

9595

96+
@pytest.mark.asyncio
97+
async def test_run_agent_task_persona_job_generates_as_persona() -> None:
98+
# A "telegram:<persona>" job (#29) is generated AS that persona (persona_name
99+
# forced) while keeping the "system" execution mode, and delivered via that
100+
# bot to its own owner (the bot's allowlist, not the global one).
101+
channel = AsyncMock()
102+
channel.config = SimpleNamespace(allowed_user_ids=[99])
103+
agent = SimpleNamespace(
104+
channels={"telegram:coach": channel},
105+
process=AsyncMock(return_value=SimpleNamespace(text="done")),
106+
config=SimpleNamespace(
107+
channels=SimpleNamespace(telegram=SimpleNamespace(allowed_user_ids=[1]))
108+
),
109+
job_store=None,
110+
)
111+
set_agent_context(agent)
112+
113+
await run_agent_task("ping", channel="telegram:coach")
114+
115+
_, kwargs = agent.process.call_args
116+
assert kwargs["persona_name"] == "coach"
117+
assert kwargs["channel"] == "system" # execution mode unchanged
118+
channel.send.assert_awaited_once_with(99, "done") # coach bot → coach's owner
119+
120+
96121
@pytest.mark.asyncio
97122
async def test_run_agent_task_marks_oneshot_done() -> None:
98123
"""One-shot jobs should be marked 'done' after execution."""

0 commit comments

Comments
 (0)