diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae7dbf5..aeeb219a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,32 @@ PyPI version — not the changelog header. gate's scope newly surfaced 9 pre-existing `unused_function` flags in its handlers — triage tracked separately. (DPLAN-0218) +### Added + +- **`@api` in-process `set_secret` write-door** — `aipass.api.apps.modules.secrets.set_secret(provider, slug, value, *, as_json=False)` + mirrors the existing `get_secret`, writing `~/.secrets/aipass//.json` + (dirs `0o700`, files `0o600`, value never echoed to stdout or logged). The @api + secrets store was previously read-only; this is the writer the telegram + mother-bot needs to persist a newly-created bot's config so the child can read + its token. 515 @api tests pass (11 new), @api seedgo 100%. (DPLAN-0220) + ### Fixed +- **Telegram port — wave 1 (persistence + monitor + state hygiene)**, surfaced by + a full completeness audit against `TELEGRAM_PORT_MAP.md` (366 tags, ~83% ported, + 452/452 tests green): (1) **bot launch** — `bot_factory.start_bot_process` and + `telegram-bot@.service` used a non-existent `~/.venv/bin/python3`; now launch via + `sys.executable -m …base_bot` (added `lib/__init__.py` + `lib/telegram/__init__.py` + for package resolution, since base_bot uses relative imports). (2) **reboot + survival** — `enable_service` now installs the systemd unit to + `~/.config/systemd/user/` + `daemon-reload` (previously the unit was never + installed, so `enable` silently no-op'd). (3) **state hygiene** — gitignored + `skills/.../lib/telegram/.local/` so the runtime registry/offset/lock files stop + leaking into the repo. (4) **prax-monitor** — `log_streamer` tailed a hardcoded + `~/system_logs` while prax writes to the repo-root `system_logs`; now resolves the + repo root (honoring `AIPASS_TEST_LOG_DIR`) so the log stream actually delivers. + (DPLAN-0220) + - **seedgo CLI help checkers green-lit non-compliant `--help` output** — the `cli`/`help_text`/`introspection` standards are static source scans (they confirm a `print_help` function, `console.print`, and `--help` wiring exist) diff --git a/src/aipass/api/README.md b/src/aipass/api/README.md index 07fcd33a..e0ee8ddb 100644 --- a/src/aipass/api/README.md +++ b/src/aipass/api/README.md @@ -5,8 +5,8 @@ > Centralized external API gateway — authenticated service clients for all external APIs **Module:** `aipass.api` | **Role:** `api_gateway` -**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested) -**Last Updated:** 2026-06-15 +**Seedgo:** 100% (38/38 at 100%) | **Tests:** 515 pass | **Functions:** 84 public (84 tested) +**Last Updated:** 2026-06-24 --- @@ -68,7 +68,7 @@ api/ │ │ └── usage/aggregation.py, cleanup.py, tracking.py │ └── integrations/ # Private driver space (gitignored) │ └── {project}/driver.py -└── tests/ # 504 tests across 28 files +└── tests/ # 515 tests across 28 files ``` Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`. @@ -88,11 +88,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers from aipass.api.apps.modules.google_client import get_google_service service = get_google_service("calendar", "v3") -from aipass.api.apps.modules.secrets import get_secret, list_secrets +from aipass.api.apps.modules.secrets import get_secret, set_secret, list_secrets token = get_secret("telegram", "bot") # Returns bot_token string config = get_secret("telegram", "bot", as_json=True) # Returns full dict -slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...] -# CLI never prints raw values — use the Python API above for programmatic access +set_secret("telegram", "newbot", cfg, as_json=True) # Writes ~/.secrets/aipass/telegram/newbot.json +slugs = list_secrets("telegram") # Returns ["bot", "newbot", ...] +# Values never reach stdout — use the Python API above for programmatic access ``` --- @@ -128,6 +129,6 @@ Private drivers in `apps/integrations/{project}/driver.py` (gitignored) register --- -*Last Updated: 2026-05-16* +*Last Updated: 2026-06-24* [← Back to AIPass](../../../README.md) diff --git a/src/aipass/api/apps/handlers/auth/secrets.py b/src/aipass/api/apps/handlers/auth/secrets.py index 6cc77f93..38031c7c 100644 --- a/src/aipass/api/apps/handlers/auth/secrets.py +++ b/src/aipass/api/apps/handlers/auth/secrets.py @@ -9,17 +9,19 @@ """ Secrets Store Handler -Reads structured secrets from ~/.secrets/aipass//. +Reads and writes structured secrets in ~/.secrets/aipass//. Supports JSON config files and raw secret files. Functions: get_secret() - Read a secret by provider/slug + set_secret() - Write a secret to the provider store list_secrets() - List available slugs for a provider """ import json +import os from pathlib import Path -from typing import Any, List, Optional +from typing import Any, List, Optional, Union from aipass.prax import logger from aipass.api.apps.handlers.json import json_handler @@ -115,6 +117,53 @@ def list_secrets(provider: str) -> List[str]: return sorted(slugs) +# ============================================== +# SECRET WRITING +# ============================================== + + +def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path: + """ + Write a secret to the provider store. + + Destination: ~/.secrets/aipass//.json + + Args: + provider: Provider directory name (e.g., 'telegram') + slug: Secret identifier (without .json extension) + value: Secret value — string or dict + as_json: If True, json.dump the value; else write as plain string + + Returns: + Path to the written file + + Raises: + OSError: If directory creation or file write fails + """ + provider_dir = SECRETS_BASE / provider + provider_dir.mkdir(parents=True, exist_ok=True) + os.chmod(provider_dir, 0o700) + + target = provider_dir / f"{slug}.json" + + if as_json: + content = json.dumps(value, indent=2).encode("utf-8") + else: + content = json.dumps(str(value)).encode("utf-8") + + fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, content) + finally: + os.close(fd) + + json_handler.log_operation( + "secret_written", + {"provider": provider, "slug": slug, "format": "json" if as_json else "raw"}, + ) + return target + + # ============================================== # PRIVATE HELPERS # ============================================== diff --git a/src/aipass/api/apps/modules/secrets.py b/src/aipass/api/apps/modules/secrets.py index 327398a9..d5e44892 100644 --- a/src/aipass/api/apps/modules/secrets.py +++ b/src/aipass/api/apps/modules/secrets.py @@ -9,17 +9,19 @@ """ Secrets Module -Cross-branch in-process API for reading secrets from the provider store. +Cross-branch in-process API for the secrets provider store. Consumers import directly instead of shelling out to the CLI. Functions: get_secret() - Read a secret by provider/slug + set_secret() - Write a secret to the provider store list_secrets() - List available slugs for a provider handle_command() - Route CLI commands (seedgo module discovery) """ import sys -from typing import Any, List, Optional +from pathlib import Path +from typing import Any, List, Optional, Union from aipass.prax import logger # noqa: F401 — seedgo imports standard from aipass.cli.apps.modules import console, header @@ -42,6 +44,7 @@ def print_introspection(): console.print("[cyan]Available Workflows:[/cyan]") console.print(" • get_secret() - Read secret by provider/slug") + console.print(" • set_secret() - Write secret to provider store") console.print(" • list_secrets() - List slugs for a provider") console.print() @@ -96,6 +99,30 @@ def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any] return result +def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path: + """ + Write a secret to the provider store. + + This is the sanctioned cross-branch write path. Consumers call this + instead of shelling out to the CLI. + + Args: + provider: Provider directory name (e.g., 'telegram') + slug: Secret identifier (without .json extension) + value: Secret value — string or dict + as_json: If True, json.dump the value; else write as plain string + + Returns: + Path to the written file + + Raises: + OSError: If directory creation or file write fails + """ + result = _handler.set_secret(provider, slug, value, as_json=as_json) + json_handler.log_operation("secrets_set", {"provider": provider, "slug": slug, "wrote": str(result)}) + return result + + def list_secrets(provider: str) -> List[str]: """ List available secret slugs for a provider. diff --git a/src/aipass/api/tests/test_secrets.py b/src/aipass/api/tests/test_secrets.py index a4804592..a5d79e15 100644 --- a/src/aipass/api/tests/test_secrets.py +++ b/src/aipass/api/tests/test_secrets.py @@ -21,8 +21,19 @@ Tests — handlers/auth/secrets.py (get_secret, list_secrets): - list_secrets: non-existent provider returns empty list - list_secrets: skips dotfiles, __pycache__, directories +Tests — handlers/auth/secrets.py (set_secret): +- set_secret: writes string value to provider/slug.json +- set_secret: as_json writes JSON-serialized dict +- set_secret: creates provider directory if missing +- set_secret: file has 0o600 permissions (POSIX) +- set_secret: provider dir has 0o700 permissions (POSIX) +- set_secret: overwrites existing secret +- set_secret: round-trip with get_secret returns same value +- set_secret: round-trip with get_secret as_json returns same dict + Tests — modules/secrets.py (in-process door): - get_secret wraps handler and logs operation +- set_secret wraps handler and logs operation - list_secrets wraps handler Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout): @@ -52,6 +63,7 @@ from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401 from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection from aipass.api.apps.handlers.auth.secrets import ( get_secret, + set_secret, list_secrets, ) from aipass.api.apps.modules.api_key import get_secret_cmd @@ -491,3 +503,131 @@ class TestGetSecretCmd: get_secret_cmd(["empty_provider", "--list"]) mock_secrets.list_secrets.assert_called_once_with("empty_provider") + + +# ============================================= +# set_secret (handler) +# ============================================= + + +class TestSetSecret: + """Verifies secret writing under various conditions.""" + + def test_writes_string_value(self, tmp_path: Path) -> None: + """Writes a plain string value to provider/slug.json.""" + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + result = set_secret("telegram", "bot", "my-token-value") + + assert result == tmp_path / "telegram" / "bot.json" + assert json.loads(result.read_text(encoding="utf-8")) == "my-token-value" + + def test_as_json_writes_dict(self, tmp_path: Path) -> None: + """as_json=True writes JSON-serialized dict.""" + data = {"bot_token": "abc123", "webhook_url": "https://example.com"} + + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + result = set_secret("telegram", "bot", data, as_json=True) + + written = json.loads(result.read_text(encoding="utf-8")) + assert written == data + + def test_creates_provider_directory(self, tmp_path: Path) -> None: + """Creates provider directory if it doesn't exist.""" + assert not (tmp_path / "newprovider").exists() + + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + set_secret("newprovider", "cred", "value") + + assert (tmp_path / "newprovider").is_dir() + + @pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only") + def test_file_has_0600_permissions(self, tmp_path: Path) -> None: + """Written file has 0o600 permissions.""" + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + result = set_secret("telegram", "bot", "token") + + file_mode = stat.S_IMODE(os.stat(result).st_mode) + assert file_mode == 0o600 + + @pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only") + def test_provider_dir_has_0700_permissions(self, tmp_path: Path) -> None: + """Provider directory has 0o700 permissions.""" + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + set_secret("telegram", "bot", "token") + + dir_mode = stat.S_IMODE(os.stat(tmp_path / "telegram").st_mode) + assert dir_mode == 0o700 + + def test_overwrites_existing_secret(self, tmp_path: Path) -> None: + """Overwrites an existing secret file.""" + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + set_secret("telegram", "bot", "old-value") + set_secret("telegram", "bot", "new-value") + + content = json.loads((tmp_path / "telegram" / "bot.json").read_text(encoding="utf-8")) + assert content == "new-value" + + def test_round_trip_string(self, tmp_path: Path) -> None: + """set_secret then get_secret returns the same string value.""" + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + set_secret("telegram", "bot", "round-trip-token") + result = get_secret("telegram", "bot") + + assert result == "round-trip-token" + + def test_round_trip_json(self, tmp_path: Path) -> None: + """set_secret as_json then get_secret as_json returns the same dict.""" + data = {"bot_token": "abc123", "chat_id": 42} + + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER): + set_secret("telegram", "bot", data, as_json=True) + result = get_secret("telegram", "bot", as_json=True) + + assert result == data + + def test_logs_operation(self, tmp_path: Path) -> None: + """set_secret logs the write operation via json_handler.""" + mock_jh = MagicMock() + with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER, mock_jh), patch(PATCH_LOGGER): + set_secret("telegram", "bot", "token") + + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args[0] + assert call_args[0] == "secret_written" + assert call_args[1]["provider"] == "telegram" + assert call_args[1]["slug"] == "bot" + + +# ============================================= +# set_secret (module door) +# ============================================= + + +class TestSetSecretModule: + """Verifies the module-level set_secret wrapper.""" + + def test_set_secret_wraps_handler(self, tmp_path: Path) -> None: + """Module set_secret delegates to handler and logs the operation.""" + mock_handler = MagicMock() + mock_handler.set_secret.return_value = tmp_path / "telegram" / "bot.json" + mock_jh = MagicMock() + + with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh): + result = secrets_module.set_secret("telegram", "bot", "token-val") + + assert result == tmp_path / "telegram" / "bot.json" + mock_handler.set_secret.assert_called_once_with("telegram", "bot", "token-val", as_json=False) + mock_jh.log_operation.assert_called_once() + assert mock_jh.log_operation.call_args[0][0] == "secrets_set" + + def test_set_secret_as_json(self) -> None: + """Module set_secret passes as_json through to handler.""" + mock_handler = MagicMock() + mock_handler.set_secret.return_value = Path("/fake/path.json") + mock_jh = MagicMock() + data = {"bot_token": "abc"} + + with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh): + secrets_module.set_secret("telegram", "bot", data, as_json=True) + + mock_handler.set_secret.assert_called_once_with("telegram", "bot", data, as_json=True) diff --git a/src/aipass/skills/.gitignore b/src/aipass/skills/.gitignore index 9cf1dfc4..a926f1c5 100644 --- a/src/aipass/skills/.gitignore +++ b/src/aipass/skills/.gitignore @@ -12,3 +12,4 @@ build/ *.log *.tmp *.swp +.local/ diff --git a/src/aipass/skills/lib/__init__.py b/src/aipass/skills/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/lib/telegram/__init__.py b/src/aipass/skills/lib/telegram/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py index c1c9a0f0..be0b3365 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py @@ -31,7 +31,9 @@ All HTTP calls use urllib (stdlib). No external dependencies. # Standard library import json +import shutil import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -200,11 +202,34 @@ def set_bot_commands(bot_token: str, commands: list[dict]) -> bool: # ============================================= +def _install_service_unit() -> bool: + """Copy telegram-bot@.service into ~/.config/systemd/user/ and reload.""" + UNIT_SRC = Path(__file__).resolve().parents[2] / "telegram-bot@.service" + UNIT_DST_DIR = Path.home() / ".config" / "systemd" / "user" + UNIT_DST = UNIT_DST_DIR / "telegram-bot@.service" + + try: + UNIT_DST_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(UNIT_SRC, UNIT_DST) + subprocess.run( + ["systemctl", "--user", "daemon-reload"], + capture_output=True, + text=True, + timeout=10, + ) + logger.info("Installed service unit: %s", UNIT_DST) + return True + except OSError as e: + logger.warning("Failed to install service unit: %s", e) + return False + + def enable_service(bot_id: str) -> bool: """ Enable the systemd user service for a bot (does not start it). - Runs: systemctl --user enable telegram-bot@{bot_id} + Installs the unit file if missing, then runs: + systemctl --user enable telegram-bot@{bot_id} Args: bot_id: Bot identifier used in the service template. @@ -212,6 +237,7 @@ def enable_service(bot_id: str) -> bool: Returns: True if the service was enabled successfully, False otherwise. """ + _install_service_unit() SERVICE_NAME = f"telegram-bot@{bot_id}" try: result = subprocess.run( @@ -283,12 +309,11 @@ def start_bot_process(bot_id: str) -> bool: Returns: True if the process was launched successfully, False otherwise. """ - BASE_BOT_PATH = Path(__file__).parent / "base_bot.py" - PYTHON = str(Path.home() / ".venv" / "bin" / "python3") + MODULE_PATH = "aipass.skills.lib.telegram.apps.handlers.base_bot" try: proc = subprocess.Popen( - [PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id], + [sys.executable, "-m", MODULE_PATH, "--bot-id", bot_id], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py index 08dbd87f..ede01b01 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py @@ -37,11 +37,31 @@ from aipass.skills.apps.handlers.json import json_handler # CONSTANTS # ============================================= -SYSTEM_LOGS_DIR = Path.home() / "system_logs" BATCH_INTERVAL = 5.0 TELEGRAM_MAX_LENGTH = 4000 +def _get_system_logs_dir(): + """Resolve system_logs dir, honoring AIPASS_TEST_LOG_DIR.""" + import os + + test_dir = os.environ.get("AIPASS_TEST_LOG_DIR") + if test_dir: + p = Path(test_dir) / "system" + p.mkdir(parents=True, exist_ok=True) + return p + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "pyproject.toml").exists(): + d = parent / "system_logs" + d.mkdir(parents=True, exist_ok=True) + return d + return Path.home() / "system_logs" + + +SYSTEM_LOGS_DIR = _get_system_logs_dir() + + # ============================================= # LOG STREAMER # ============================================= diff --git a/src/aipass/skills/lib/telegram/telegram-bot@.service b/src/aipass/skills/lib/telegram/telegram-bot@.service index 685a6a75..f668477d 100644 --- a/src/aipass/skills/lib/telegram/telegram-bot@.service +++ b/src/aipass/skills/lib/telegram/telegram-bot@.service @@ -19,7 +19,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%h/.venv/bin/python3 %h/Projects/AIPass/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py --bot-id %i +ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.skills.lib.telegram.apps.handlers.base_bot --bot-id %i WorkingDirectory=%h/Projects/AIPass Environment=AIPASS_BOT_ID=%i Environment=AIPASS_SESSION_TYPE=telegram