Compare commits
7 Commits
ee327d4dc2
...
0d4d930b29
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d4d930b29 | |||
| 3866461bf4 | |||
| 664c67a272 | |||
| 26a1a51cbb | |||
| e82bbb587f | |||
| c89a0d334a | |||
| ac9b6d593f |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
+16
-23
@@ -82,6 +82,14 @@ class EgressAddon:
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
|
||||
def _block(self, flow: http.HTTPFlow, reason: str) -> None:
|
||||
sys.stderr.write(f"{reason}\n")
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
reason.encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow) -> None:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
@@ -100,25 +108,18 @@ class EgressAddon:
|
||||
scan_text = auth_header + "\n" + body
|
||||
dlp_result = scan_outbound(route, scan_text, os.environ)
|
||||
if dlp_result is not None and dlp_result.severity == "block":
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
f"egress DLP: {dlp_result.reason}".encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
self._block(flow, f"egress DLP: {dlp_result.reason}")
|
||||
return
|
||||
|
||||
# Strip inbound Authorization — agent cannot smuggle tokens.
|
||||
flow.request.headers.pop("authorization", None)
|
||||
|
||||
if is_git_push_request(request_path, query):
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
(
|
||||
b"egress: git push over HTTPS is not supported; "
|
||||
b"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
b"git-gate's pre-receive hook)."
|
||||
),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
self._block(
|
||||
flow,
|
||||
"egress: git push over HTTPS is not supported; "
|
||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
"git-gate's pre-receive hook).",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -135,11 +136,7 @@ class EgressAddon:
|
||||
)
|
||||
|
||||
if decision.action == "block":
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
decision.reason.encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
self._block(flow, decision.reason)
|
||||
return
|
||||
|
||||
if decision.inject_authorization is not None:
|
||||
@@ -159,11 +156,7 @@ class EgressAddon:
|
||||
if result is None:
|
||||
return
|
||||
if result.severity == "block":
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
f"egress DLP: {result.reason}".encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
self._block(flow, f"egress DLP: {result.reason}")
|
||||
elif result.severity == "warn":
|
||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -120,11 +120,10 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
# is intentionally unreachable — the pre-receive
|
||||
# gitleaks hook must reject BEFORE git-gate
|
||||
# attempts the upstream push.
|
||||
"git": {"remotes": {
|
||||
"unreachable.invalid": {
|
||||
"Name": "throwaway",
|
||||
"Upstream": "ssh://git@unreachable.invalid:22/throwaway.git",
|
||||
"IdentityFile": str(cls._key_path),
|
||||
"git-gate": {"repos": {
|
||||
"throwaway": {
|
||||
"url": "ssh://git@unreachable.invalid:22/throwaway.git",
|
||||
"identity": str(cls._key_path),
|
||||
},
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -110,10 +110,10 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
# (high-numbered) so we're confirming TSI refusal, not
|
||||
# just "no service listening."
|
||||
r = self.bottle.exec(
|
||||
"wget -T 3 -t 1 -O - http://127.0.0.1:9 2>&1 || true"
|
||||
"curl -s --show-error --max-time 3 http://127.0.0.1:9 2>&1 || true"
|
||||
)
|
||||
# `wget` to a denied destination produces a connect error.
|
||||
# The exact phrasing varies (busybox vs gnu); we assert
|
||||
# `curl` to a denied destination produces a connect error.
|
||||
# The exact phrasing varies by curl version; we assert
|
||||
# the response is NOT the body of any real service.
|
||||
self.assertNotIn("hello-from-vm", r.stdout)
|
||||
self.assertTrue(
|
||||
@@ -126,10 +126,10 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
|
||||
def test_prompt_file_lands_in_guest(self):
|
||||
# provision_prompt copies the host-side prompt.txt into the
|
||||
# guest at /root/.bot-bottle-prompt.txt. The content
|
||||
# guest at /home/node/.bot-bottle-prompt.txt. The content
|
||||
# must match what the manifest declared so claude-code's
|
||||
# --append-system-prompt-file reads the right text.
|
||||
r = self.bottle.exec("cat /root/.bot-bottle-prompt.txt")
|
||||
r = self.bottle.exec("cat /home/node/.bot-bottle-prompt.txt")
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n"))
|
||||
|
||||
@@ -143,7 +143,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
# connect fails, which is the property chunk 3 will
|
||||
# preserve once egress is actually running.
|
||||
r = self.bottle.exec(
|
||||
f"wget -T 3 -t 1 -O - http://{self.plan.bundle_ip}:9099 "
|
||||
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
|
||||
"2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user