From 8421ec8b4ae2e5a7e2cc0c14cc5f8e098f8bfdd7 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:25:10 +0400 Subject: [PATCH] fix: plugin allowlist matching and windows session paths (#72) --- readme.md | 4 ++-- src/takopi/directives.py | 5 ++++- src/takopi/plugins.py | 11 +++++++++-- src/takopi/runners/pi.py | 6 +++--- tests/test_pi_runner.py | 18 ++++++++++++++++-- tests/test_plugins.py | 17 +++++++++++++++++ 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 038ed0d..aa8be23 100644 --- a/readme.md +++ b/readme.md @@ -123,7 +123,7 @@ takopi opencode takopi pi ``` -list available plugins (engines/transports), and override in a run: +list available plugins (engines/transports/commands), and override in a run: ```sh takopi plugins @@ -147,7 +147,7 @@ if you prefer no notifications, `--no-final-notify` edits the progress message i ## plugins -Takopi supports entrypoint-based plugins for engines and transports. +Takopi supports entrypoint-based plugins for engines, transports, and command backends. See: diff --git a/src/takopi/directives.py b/src/takopi/directives.py index 1cd1dfb..15d50e3 100644 --- a/src/takopi/directives.py +++ b/src/takopi/directives.py @@ -126,7 +126,10 @@ def parse_context_line( branch = tokens[1][1:] project_key = project.lower() if project_key not in projects.projects: - raise DirectiveError(f"unknown project {project!r} in ctx line") + raise DirectiveError( + f"unknown project {project!r} in ctx line; start a new thread or " + "add it back to your config" + ) ctx = RunContext(project=project_key, branch=branch) return ctx diff --git a/src/takopi/plugins.py b/src/takopi/plugins.py index 79cae68..d706d6e 100644 --- a/src/takopi/plugins.py +++ b/src/takopi/plugins.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass from importlib.metadata import EntryPoint, entry_points +import re from typing import Any, Callable from .ids import ID_PATTERN, is_valid_id @@ -11,6 +12,8 @@ ENGINE_GROUP = "takopi.engine_backends" TRANSPORT_GROUP = "takopi.transport_backends" COMMAND_GROUP = "takopi.command_backends" +_CANONICAL_NAME_RE = re.compile(r"[-_.]+") + @dataclass(frozen=True, slots=True) class PluginLoadError: @@ -111,7 +114,11 @@ def entrypoint_distribution_name(ep: EntryPoint) -> str | None: def normalize_allowlist(allowlist: Iterable[str] | None) -> set[str] | None: if allowlist is None: return None - cleaned = {item.strip().lower() for item in allowlist if item and item.strip()} + cleaned = { + _CANONICAL_NAME_RE.sub("-", item.strip()).lower() + for item in allowlist + if item and item.strip() + } return cleaned or None @@ -121,7 +128,7 @@ def is_entrypoint_allowed(ep: EntryPoint, allowlist: set[str] | None) -> bool: dist_name = entrypoint_distribution_name(ep) if dist_name is None: return False - return dist_name.lower() in allowlist + return _CANONICAL_NAME_RE.sub("-", dist_name).lower() in allowlist def _entrypoint_sort_key(ep: EntryPoint) -> tuple[str, str, str]: diff --git a/src/takopi/runners/pi.py b/src/takopi/runners/pi.py index 714950a..eeeee1e 100644 --- a/src/takopi/runners/pi.py +++ b/src/takopi/runners/pi.py @@ -4,7 +4,7 @@ import os import re from dataclasses import dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePath from typing import Any from uuid import uuid4 @@ -445,10 +445,10 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner): return f'"{escaped}"' -def _default_session_dir(cwd: Path) -> Path: +def _default_session_dir(cwd: PurePath) -> Path: agent_dir = os.environ.get("PI_CODING_AGENT_DIR") base = Path(agent_dir).expanduser() if agent_dir else Path.home() / ".pi" / "agent" - safe_path = f"--{str(cwd).lstrip('/\\\\').replace('/', '-').replace('\\\\', '-').replace(':', '-')}--" + safe_path = f"--{str(cwd).lstrip('/\\\\').replace('/', '-').replace('\\', '-').replace(':', '-')}--" return base / "sessions" / safe_path diff --git a/tests/test_pi_runner.py b/tests/test_pi_runner.py index 8593fe4..25ce5d2 100644 --- a/tests/test_pi_runner.py +++ b/tests/test_pi_runner.py @@ -1,11 +1,17 @@ -from pathlib import Path +from pathlib import Path, PureWindowsPath from unittest.mock import patch import anyio import pytest from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent -from takopi.runners.pi import ENGINE, PiRunner, PiStreamState, translate_pi_event +from takopi.runners.pi import ( + ENGINE, + PiRunner, + PiStreamState, + _default_session_dir, + translate_pi_event, +) from takopi.schemas import pi as pi_schema @@ -152,3 +158,11 @@ def test_session_path_prefers_run_base_dir(tmp_path: Path) -> None: default_session_dir.assert_called_once_with(project_cwd) assert str(session_root) in session_path + + +def test_session_path_sanitizes_windows_separators() -> None: + cwd = PureWindowsPath("C:\\foo\\bar") + session_dir = _default_session_dir(cwd) + name = session_dir.name + assert "\\" not in name + assert ":" not in name diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 332ad8f..57c8452 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -97,6 +97,23 @@ def test_allowlist_filters_by_distribution(monkeypatch) -> None: assert ids == ["codex"] +def test_allowlist_canonicalizes_distribution_names(monkeypatch) -> None: + entrypoints = [ + FakeEntryPoint( + "slack", + "takopi.transport.slack:BACKEND", + plugins.TRANSPORT_GROUP, + dist_name="takopi-transport-slack", + ) + ] + install_entrypoints(monkeypatch, entrypoints) + + ids = plugins.list_ids( + plugins.TRANSPORT_GROUP, allowlist=["takopi_transport.slack"] + ) + assert ids == ["slack"] + + def test_validator_errors_are_captured(monkeypatch) -> None: entrypoints = [ FakeEntryPoint(