Compare commits

...

5 Commits

Author SHA1 Message Date
didericis-claude ee327d4dc2 docs: bump PRD number from 0052 to 0053
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 41s
Renames docs/prds/0052-user-provider-plugins.md to 0053-user-provider-plugins.md
and updates the heading inside the file. 0052 is now reserved for the egress
DLP addon.
2026-06-06 12:30:21 -04:00
didericis-claude 8f5b1dd548 fix: correct broken imports and fileno() guard after rebase
codex_auth.py was moved into contrib/codex/ but still used `.log`/
`.util` relative imports that resolved to the parent bot_bottle
package before the move — update to `...log` / `...util`.

_read_winsize() called sys.stdin.fileno() outside the OSError guard;
pytest's redirected stdin raises UnsupportedOperation (an OSError
subclass) there, breaking test_returns_first_tty_size. Move fileno()
inside the try block so any non-TTY stream is skipped cleanly.
2026-06-06 12:30:21 -04:00
didericis-claude 61b62261e9 docs: PRD 0052 — user-defined agent provider plugins
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:30:21 -04:00
didericis-claude af57588e0b refactor: move codex_auth into contrib/codex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:30:21 -04:00
didericis-claude 8c0a9c5bc6 docs: rename PRD 0053 to PRD 0052
Renames docs/prds/0053-egress-dlp-addon.md to 0052-egress-dlp-addon.md
and updates all references in the documentation.
2026-06-06 16:27:04 +00:00
7 changed files with 195 additions and 8 deletions
@@ -68,8 +68,9 @@ def _read_winsize() -> tuple[int, int] | None:
- tmux respawn-pane: tmux sets all three to the pane's PTY.
- non-TTY (someone piped stdin in tests): none are; the
sync just no-ops, which is the right behavior."""
for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()):
for stream in (sys.stdin, sys.stdout, sys.stderr):
try:
fd = stream.fileno()
data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
except OSError:
continue
+1 -1
View File
@@ -23,7 +23,7 @@ from ...agent_provider import (
AgentProvisionFile,
AgentProvisionPlan,
)
from ...codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
from ...log import die, info, warn
@@ -15,8 +15,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import cast
from .log import die
from .util import expand_tilde
from ...log import die
from ...util import expand_tilde
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
@@ -1,4 +1,4 @@
# PRD 0053: Egress DLP addon
# PRD 0052: Egress DLP addon
- **Status:** Active
- **Author:** claude
@@ -397,7 +397,7 @@ afterward, preserving the existing credential-injection security model.
4. **Naive prompt injection detector (Phase 2).**
Add `NaiveInjectionDetector` to `dlp_detectors.py`. Wire
`scan_inbound` into the new `response` hook in `egress_addon.py`.
Extend unit tests. Activate PRD 0053 (`Status: Draft → Active`) in
Extend unit tests. Activate PRD 0052 (`Status: Draft → Active`) in
this commit.
## Open questions
+186
View File
@@ -0,0 +1,186 @@
# PRD 0053: User-defined agent provider plugins
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-06-04
## Summary
The `get_provider()` registry in `bot_bottle/agent_provider.py` is a closed list —
only `"claude"` and `"codex"` are valid templates, validated at manifest-load time and
again at launch. Users who want to run a different agent (Gemini, Aider, a custom
local model wrapper) cannot add a provider without forking the package.
This PRD opens the registry to user-defined plugins. A plugin placed at
`~/.bot-bottle/contrib/<name>/agent_provider.py` is discovered and loaded at launch
time. The manifest accepts any non-empty template string that names a built-in or
resolves to a user plugin at that path. No changes to the built-in providers or the
internal `bot_bottle/contrib/` layout.
The preceding commit on this PR moves `codex_auth.py` from `bot_bottle/` into
`bot_bottle/contrib/codex/` — a clean-up that fits naturally here since this PR
also clarifies that `contrib/` is the per-provider home.
## Problem
Users building unconventional setups hit a hard wall: the template validation in
`manifest_agent.AgentProvider.from_dict` rejects any string not in `PROVIDER_TEMPLATES`.
There is no escape hatch short of editing bot-bottle's source.
PRD 0050 moved provider logic into `contrib/` specifically so a third provider would
be "cheap to add" — but "cheap" today still means a pull request against the bot-bottle
repo, not a drop-in file in the user's home directory. The filesystem layout is already
the right shape; the discovery step is missing.
## Goals / Success Criteria
1. A user places `~/.bot-bottle/contrib/<name>/agent_provider.py` — a file that exports
a class inheriting `AgentProvider` — sets `agent_provider.template: <name>` in a
bottle's frontmatter, and launches a bottle using that provider with no changes to
the bot-bottle source.
2. The manifest validator accepts any non-empty template string. Unknown templates that
resolve to no user plugin still raise a clear error, but at launch (via `get_provider`)
rather than at manifest-load time.
3. Built-in provider knobs (`auth_token` → claude only; `forward_host_credentials`
codex only) are guarded to built-in template names. Bottles using a user provider
may set neither knob.
4. `get_provider(template)` checks `~/.bot-bottle/contrib/<template>/agent_provider.py`
before the built-ins, so a user can shadow a built-in for local testing.
5. A clear `ValueError` is raised if the user plugin file exists but contains no
`AgentProvider` subclass.
## Non-goals
- Packaging or distributing user plugins as installable Python packages.
- A plugin registry, index, or discovery beyond the filesystem path convention.
- Adding a third built-in provider.
- Changing the `AgentProvider` ABC contract — user plugins implement the same abstract
methods as `ClaudeAgentProvider` and `CodexAgentProvider`.
- Validating that user plugin images, Dockerfiles, or commands exist before launch
(same policy as built-ins).
- Sandboxing user plugin code — plugins run with full Python interpreter access.
## Scope
### In scope
- `get_provider(template: str) -> AgentProvider` gains a `_load_user_plugin(template)`
step that checks `~/.bot-bottle/contrib/<template>/agent_provider.py` first, then
falls through to the built-in look-ups.
- `_load_user_plugin` uses `importlib.util.spec_from_file_location` to load the module
and returns the first `AgentProvider` subclass found in its `__dict__`. Raises
`ValueError` if the file exists but exports no subclass.
- `manifest_agent.AgentProvider.from_dict`: the `template not in PROVIDER_TEMPLATES`
check is removed; the two built-in-specific knob guards (`auth_token` → claude,
`forward_host_credentials` → codex) are tightened to `template in PROVIDER_TEMPLATES`
so they are skipped for user-defined names.
- `PROVIDER_TEMPLATES` remains in `agent_provider.py` as the set of built-in names for
use by tests and any enumeration callers.
- Unit tests for the discovery path:
- Plugin found and loaded → correct `AgentProvider` instance returned.
- Plugin file exists but exports no subclass → `ValueError`.
- Unknown template with no user plugin → `ValueError` from `get_provider`.
- Built-in template name still works normally even when no user plugin exists.
- One paragraph added to `README.md` under a new "Custom providers" section describing
the `~/.bot-bottle/contrib/<name>/agent_provider.py` convention and pointing at the
existing contrib providers as reference implementations.
### Out of scope
- Hot-reloading plugins during a running session.
- Plugin versioning or dependency declaration.
- Changes to smolmachines or Docker backend provisioning paths.
## Proposed design
### Discovery in `get_provider`
```python
import importlib.util
def get_provider(template: str) -> AgentProvider:
user_plugin = _load_user_plugin(template)
if user_plugin is not None:
return user_plugin
if template == PROVIDER_CLAUDE:
from .contrib.claude.agent_provider import ClaudeAgentProvider
return ClaudeAgentProvider()
if template == PROVIDER_CODEX:
from .contrib.codex.agent_provider import CodexAgentProvider
return CodexAgentProvider()
raise ValueError(f"unknown agent provider template: {template!r}")
def _load_user_plugin(template: str) -> AgentProvider | None:
plugin_path = (
Path.home() / ".bot-bottle" / "contrib" / template / "agent_provider.py"
)
if not plugin_path.exists():
return None
spec = importlib.util.spec_from_file_location(
f"_user_contrib_{template}.agent_provider", plugin_path
)
if spec is None or spec.loader is None:
raise ValueError(f"user plugin at {plugin_path} could not be loaded")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
for obj in vars(mod).values():
if (
isinstance(obj, type)
and issubclass(obj, AgentProvider)
and obj is not AgentProvider
):
return obj()
raise ValueError(
f"user plugin at {plugin_path} defines no AgentProvider subclass"
)
```
### Manifest validation change
In `manifest_agent.AgentProvider.from_dict`, remove the hard rejection:
```python
# Before
if template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.template {template!r} "
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
)
# After — removed entirely; get_provider() raises at launch for unknown names
```
Guard the built-in knob checks with `template in PROVIDER_TEMPLATES`:
```python
if auth_token and template == "claude": # unchanged
...
if auth_token and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only "
f"supported for built-in templates ({', '.join(sorted(PROVIDER_TEMPLATES))})"
)
if forward_host_credentials and template == "codex": # unchanged
...
if forward_host_credentials and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"is only supported for built-in templates"
)
```
## Open questions
1. **Shadow order.** This PRD puts user plugins before built-ins, allowing local
overrides. If the preference is built-ins-first (to prevent accidental shadowing),
swap the order and document accordingly.
2. **`BOT_BOTTLE_CONTRIB_DIR` env var.** Omitted for now — `~/.bot-bottle/contrib/`
is consistent with the rest of the user config layout. Revisit if the need surfaces.
## References
- PRD 0050 — agent provider contrib (established `contrib/` as the per-provider home)
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention)
- `bot_bottle/agent_provider.py``get_provider`, `PROVIDER_TEMPLATES`, `AgentProvider` ABC
- `bot_bottle/manifest_agent.py` — template validation that this PRD relaxes
+1 -1
View File
@@ -3,7 +3,7 @@
## Question
Bot-bottle's egress manifest currently supports exact-host matching and
a flat list of path prefixes (`path_allowlist`). As the DLP work (PRD 0053)
a flat list of path prefixes (`path_allowlist`). As the DLP work (PRD 0052)
and future route hardening evolve, we may want more expressive matching:
glob-style path patterns (`/api/*/data`), header predicates (Content-Type,
Accept), and per-method rules (GET allowed, POST blocked). What established
+1 -1
View File
@@ -9,7 +9,7 @@ import unittest
from datetime import datetime, timezone
from pathlib import Path
from bot_bottle.codex_auth import (
from bot_bottle.contrib.codex.codex_auth import (
codex_auth_path,
codex_dummy_auth_json,
codex_host_access_token,