Compare commits

..

8 Commits

Author SHA1 Message Date
didericis a4895a3bb2 fix: remove deprecated/unrecognized pylint options from config
Lint and Type Check / lint (push) Failing after 6m58s
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Failing after 50s
Remove options that are not supported in the current pylint version:
- allow-any-import-level, allow-reexport-from-package, etc.
- ext-import-graph, import-graph, int-import-graph
- deprecated-modules, preferred-modules

Keep only widely-supported known-third-party option for compatibility
across different pylint versions and VSCode environments.

Fixes: Pylint(E0015:unrecognized-option) error in VSCode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:13:08 -04:00
didericis 86bb8e1908 fix: update pipelock constant imports in compose.py
Lint and Type Check / lint (push) Failing after 6m52s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Failing after 47s
Move PIPELOCK_CA_CERT_IN_CONTAINER and PIPELOCK_CA_KEY_IN_CONTAINER
imports from the docker-specific pipelock module to the platform-neutral
bot_bottle.pipelock module, where they are actually defined. Keep
PIPELOCK_PORT from the docker module as it is docker-specific.

Fixes import error: cannot import name 'PIPELOCK_CA_CERT_IN_CONTAINER'
from 'bot_bottle.backend.docker.pipelock'

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:10:22 -04:00
didericis 0ca81b102c ci: add dev requirements file and update workflow
Lint and Type Check / lint (push) Failing after 7m5s
test / unit (pull_request) Failing after 26s
test / integration (pull_request) Failing after 15s
Create requirements-dev.txt with pylint and pyright. The bot-bottle
project itself has no runtime dependencies. Update workflow to use
the requirements file for pip caching.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:07:59 -04:00
didericis 4e185fab6b refactor: fix unused imports, long lines, and type issues
Lint and Type Check / lint (push) Failing after 1m57s
test / unit (pull_request) Failing after 30s
test / integration (pull_request) Failing after 16s
Remove 35+ unused imports across 20+ files (W0611). Wrap 19 lines
to fit under 100 character limit (C0301). Add type casts and
annotations in egress_addon_core.py to resolve pyright errors
caused by JSON parsing of untyped objects.

Key changes:
- Remove unused imports (abstractmethod, mock utilities, etc)
- Split long lines at logical breaks (method calls, error messages)
- Add typing.cast() for proper type inference in JSON parsing
- Explicit type annotations for dict/list accesses

Results:
- Pylint rating: 8.73/10
- egress_addon_core.py: 0 pyright errors (was 15)
- All W0611 and C0301 issues fixed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:04:17 -04:00
didericis f665d62712 config: add pylint configuration
Add default .pylintrc with pylint's standard configuration. This
allows for local customization of linting rules and provides a
baseline for code quality checks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:03:57 -04:00
didericis 7b8f40a5f0 ci: add pylint and pyright linting workflow
Add Gitea workflow to run pylint and pyright on all Python files
when they are pushed. The workflow triggers on any .py file changes
and enforces a quality threshold.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-03 23:03:09 -04:00
didericis-claude 605a70408e feat(cli): add launch selector TUI for start command (PRD 0051)
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 41s
test / unit (push) Successful in 35s
test / integration (push) Successful in 42s
- Add bot_bottle/cli/tui.py: curses filter-select picker that opens
  /dev/tty directly so it works with redirected stdout/stdin
- Make `name` positional optional (nargs="?") in cmd_start; show agent
  picker when absent
- Show backend picker when no --backend flag and BOT_BOTTLE_BACKEND is
  unset; skip when either is explicit or the env var is present
- Add tests/unit/test_cli_tui.py covering _filter_items logic and
  short-circuit paths (empty list, unavailable tty)
- Add tests/unit/test_cli_start_selector.py covering all four dispatch
  combinations (both explicit, agent-absent, backend-absent, both-absent)
  and cancel semantics
- Activate PRD 0051
2026-06-04 01:54:53 +00:00
didericis-claude 832808ff9a docs(prd): draft PRD 0051 — launch selector TUI
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 40s
2026-06-04 01:52:29 +00:00
45 changed files with 1376 additions and 355 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Lint and Type Check
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'
cache: 'pip'
cache-dependency-path: requirements-dev.txt
- 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 .
+624
View File
@@ -0,0 +1,624 @@
[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
[IMPORTS]
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[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
# 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
@@ -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,
) )
+2 -2
View File
@@ -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,
+5 -12
View File
@@ -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}"
@@ -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"
),
) )
@@ -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
+12 -3
View File
@@ -41,9 +41,18 @@ def usage() -> None:
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") " resume re-launch a bottle by its identity "
sys.stderr.write(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n") "(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")
+18 -5
View File
@@ -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)
+28 -3
View File
@@ -33,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:
@@ -49,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,
) )
@@ -65,7 +90,7 @@ 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,
) )
+219
View File
@@ -0,0 +1,219 @@
"""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 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:
result = _run_picker(items, title=title, tty_fd=tty_fd)
finally:
tty_fd.close()
return result
# ---------------------------------------------------------------------------
# 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) -> 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.
old_term = os.environ.get("TERM", "xterm-256color")
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(tty_fd, 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:
return None
finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment]
sys.__stdout__ = orig_stdout # type: ignore[assignment]
return result
def _picker_loop(screen, 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, 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, row: int, col: int, text: str, attr: int = curses.A_NORMAL) -> None:
try:
screen.addstr(row, col, text, attr)
except curses.error:
pass
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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"
+11 -7
View File
@@ -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
View File
@@ -32,7 +32,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import os 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
+5 -3
View File
@@ -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
+12 -3
View File
@@ -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.
+2 -1
View File
@@ -214,7 +214,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:
+8 -2
View File
@@ -30,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}")
+1 -1
View File
@@ -20,7 +20,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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",
-283
View File
@@ -1,283 +0,0 @@
# PRD 0049: Named / Labelled Agents
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-06-03
- **Issue:** #171
## Summary
At agent launch time, prompt the operator for a short human-readable label
(defaulting to the manifest agent key) and an optional color from the 16-color
ANSI palette. Store both in the bottle's `metadata.json`. Display the label —
rendered in the chosen color — in the dashboard's active-agents pane, replacing
the bare manifest key. Inject the label and color into the in-container
`claude.json` as `name` / `color` so Claude Code can surface them in its own
harness when upstream support lands.
## Problem
The dashboard's agents pane identifies each running instance by its manifest
agent key (e.g., `implementer`) plus a random slug suffix. When an operator
runs three `implementer` bottles simultaneously — one each for three different
repos — the pane shows:
```
[docker] a3f9 implementer started 14:02:11 [egress,pipelock]
[docker] b81c implementer started 14:03:45 [egress,pipelock]
[docker] d220 implementer started 14:05:01 [egress,pipelock]
```
There is no way to tell which bottle is working on which task without attaching
to each one in turn. The slug is opaque; the manifest key is shared. Operators
working a multi-bottle session resort to keeping a mental map of slug→task,
which breaks the moment they switch windows.
## Goals / Success Criteria
1. After the operator selects an agent name (dashboard picker or CLI argument),
they are prompted for a label. The prompt suggests the manifest key as the
default; pressing Enter (or providing no input) accepts it. The label may
contain any printable characters up to 64 bytes.
2. After the label prompt, the operator is optionally prompted for a color from
the 16-color ANSI palette (names: `black`, `red`, `green`, `yellow`, `blue`,
`magenta`, `cyan`, `white`, `bright-black`, `bright-red`, `bright-green`,
`bright-yellow`, `bright-blue`, `bright-magenta`, `bright-cyan`,
`bright-white`). Pressing Enter without a selection skips color entirely.
3. `label` and `color` are stored in `BottleMetadata` and written to the
bottle's `metadata.json`. Both fields default to `""` (empty / unset).
4. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
from `metadata.json`.
5. `_format_agent_row` uses the label when non-empty (falling back to
`agent_name`). If a non-empty color is set and the terminal supports it, the
label substring is rendered in that color.
6. `BottleSpec` carries `label` and `color`; the docker backend's `prepare`
step copies them into `BottleMetadata`.
7. `agent_provider.py` writes `label``"name"` and `color``"color"` into
the generated `claude.json`, alongside the existing fields. Fields are
omitted when empty.
8. The dashboard's `_new_agent_flow` (PRD 0020) includes the label+color step
between agent selection and the backend picker.
9. `cmd_start` (CLI) includes the label+color step after argument validation
and before prepare-with-preflight.
10. All existing unit tests stay green; no new tests are required for this
change (the label/color fields are thin plumbing with no branching logic
worth unit-testing beyond the already-tested metadata read/write path).
## Non-goals
- Showing the agent label inside the Claude Code TUI (status line, terminal
title, custom header). That requires upstream Claude Code / codex support.
Writing to `claude.json` is best-effort scaffolding for when that lands.
- Per-bottle color affecting anything outside the dashboard agents pane (e.g.,
proposal-pane highlights, log prefixes).
- Validating or constraining label content beyond the 64-byte printable cap.
- Persisting color-pair state across dashboard restarts (color pairs are
initialized fresh each session).
- Editing the label or color of an already-running bottle.
- Exposing label/color via `./cli.py list` (out of scope for v1; trivial to
add later since the field will be in metadata).
## Design
### Data flow
```
operator input
BottleSpec.label, BottleSpec.color
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
└─► agent_provider.py → claude.json {"name": label, "color": color}
(omitted when empty)
dashboard refresh
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
_format_agent_row → label (colored) in the row string
```
### BottleSpec changes
```python
@dataclass(frozen=True)
class BottleSpec:
manifest: Manifest
agent_name: str
copy_cwd: bool
user_cwd: str
identity: str = ""
label: str = "" # operator-chosen display name; defaults to agent_name at render time
color: str = "" # one of the 16 ANSI color names, or "" for terminal default
```
`label` and `color` default to `""` so all existing callers remain valid with
no changes.
### BottleMetadata changes
Add two new fields with backward-compatible defaults:
```python
@dataclass
class BottleMetadata:
identity: str
agent_name: str
cwd: str
copy_cwd: bool
started_at: str
compose_project: str
backend: str
label: str = ""
color: str = ""
```
`metadata.json` written by older bot-bottle versions won't have these keys;
`read_metadata` already uses `dict.get` with defaults, so existing slugs load
cleanly with `label=""`, `color=""`.
### ActiveAgent changes
```python
@dataclass(frozen=True)
class ActiveAgent:
backend_name: str
slug: str
agent_name: str
started_at: str
services: tuple[str, ...]
label: str = ""
color: str = ""
```
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
constructing each `ActiveAgent`. The smolmachines backend gets the same
additions for symmetry; it reads from its own metadata path.
### Dashboard row rendering
`_format_agent_row` already falls through cleanly on missing fields. The
change is:
```python
display_name = a.label if a.label else a.agent_name
```
Color rendering uses the existing `_try_init_green()` pattern as a model.
A `_color_pair_for(color_name)` helper initialises a fresh curses color pair
for the requested named color and returns its attr (or 0 on failure). Each
unique color in the active agent list gets its own pair index. Color pairs are
allocated lazily and cached in a `dict[str, int]` that lives for the duration
of the dashboard session.
The 16 ANSI color name → curses constant mapping:
| Name | curses constant |
|------|----------------|
| `black` | `curses.COLOR_BLACK` |
| `red` | `curses.COLOR_RED` |
| `green` | `curses.COLOR_GREEN` |
| `yellow` | `curses.COLOR_YELLOW` |
| `blue` | `curses.COLOR_BLUE` |
| `magenta` | `curses.COLOR_MAGENTA` |
| `cyan` | `curses.COLOR_CYAN` |
| `white` | `curses.COLOR_WHITE` |
| `bright-*` | same constant + `curses.A_BOLD` |
Terminals that don't support color fall back to plain text (the helper returns
0, which ORed in is a no-op — same pattern as `_try_init_green`).
### Label + color prompt — dashboard
In `_new_agent_flow`, after `_picker_modal` returns a non-None name and before
`_backend_picker_modal`:
```python
label, color = _label_color_modal(stdscr, default_label=picked)
```
`_label_color_modal` uses `curses.endwin()` → text-mode prompts → restore
(the same drop-and-resume pattern as the existing editor flow and preflight
Y/N). Two sequential prompts:
```
bot-bottle: agent label [implementer]: <operator types>
bot-bottle: color (red/green/blue/… or Enter to skip): <operator types>
```
Invalid color names are silently ignored (treated as empty). The function
returns `(label, color)` — both strings, both possibly `""`.
### Label + color prompt — CLI
In `cmd_start`, after argument parsing and before `_launch_bottle`:
```python
label = _text_prompt_label(args.name)
color = _text_prompt_color()
```
`_text_prompt_label(default)` writes `"bot-bottle: agent label [{default}]: "`
to stderr and returns the stripped input (or `default` if blank).
`_text_prompt_color()` writes the color prompt and returns the stripped input
(or `""` if blank or invalid).
Both use `read_tty_line()` (already in `start.py`) for the read.
### Claude Code config injection
In `agent_provider.py`, where `claude_config.write_text(...)` is called,
expand the JSON dict conditionally:
```python
payload = {
"hasCompletedOnboarding": True,
"theme": "dark",
"bypassPermissionsModeAccepted": True,
"projects": claude_projects,
}
if spec.label:
payload["name"] = spec.label
if spec.color:
payload["color"] = spec.color
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
```
`spec` here is the `AgentProvisionSpec` (or equivalent) that `agent_provider`
already receives; it needs `label` and `color` threaded in from `BottleSpec`
through whatever plan/provision object the provider operates on.
## Implementation chunks
Two PRs, each independently mergeable.
### Chunk 1 — schema + storage
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
`BottleMetadata`, and `ActiveAgent`.
- `docker/prepare.py`: copy `spec.label` / `spec.color` into `BottleMetadata`.
- `docker/enumerate.py`: copy `metadata.label` / `metadata.color` into
`ActiveAgent`.
- `agent_provider.py` (or the plan object it reads): thread label/color through
to `claude.json` write.
- Smolmachines backend: parallel changes to metadata read/write and
`ActiveAgent` construction.
- No prompt changes; no UI changes. All existing behavior is identical.
### Chunk 2 — prompts + display
- `start.py`: add `_text_prompt_label` and `_text_prompt_color`; call them in
`cmd_start` before `_launch_bottle`; pass `label` / `color` into `BottleSpec`.
- `dashboard.py`: add `_label_color_modal` (drop-and-resume); call it in
`_new_agent_flow`; pass label/color into `BottleSpec`; add
`_color_pair_for` helper; update `_format_agent_row` to use `a.label` with
color rendering.
## Open questions
None.
+157
View File
@@ -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.
+6
View File
@@ -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
+1 -2
View File
@@ -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,
@@ -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
-1
View File
@@ -11,7 +11,6 @@ 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
+1 -1
View File
@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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
+141
View File
@@ -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()
+50
View File
@@ -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()
@@ -14,7 +14,6 @@ from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
AgentProvisionCommand, AgentProvisionCommand,
AgentProvisionDir,
AgentProvisionFile, AgentProvisionFile,
AgentProvisionPlan, AgentProvisionPlan,
) )
+1 -3
View File
@@ -6,9 +6,7 @@ import json
import unittest import unittest
import urllib.error import urllib.error
from io import BytesIO from io import BytesIO
from pathlib import Path from unittest.mock import MagicMock, patch
from tempfile import mkdtemp
from unittest.mock import MagicMock, call, patch
from bot_bottle.contrib.gitea.deploy_key_provisioner import ( from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner, GiteaDeployKeyProvisioner,
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
from bot_bottle.manifest import ManifestError from bot_bottle.manifest import ManifestError
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, call from unittest.mock import MagicMock
from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend import Bottle, BottleSpec, ExecResult
+1 -1
View File
@@ -7,7 +7,7 @@ auth omission means unauthenticated."""
import unittest import unittest
from bot_bottle.manifest import ManifestError, EgressRoute, Manifest from bot_bottle.manifest import ManifestError, Manifest
def _bottle(routes): def _bottle(routes):
+1 -1
View File
@@ -10,7 +10,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import cast
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import ( from bot_bottle.pipelock import (
+5 -1
View File
@@ -220,7 +220,11 @@ class TestEgressPrintParity(unittest.TestCase):
indent_prefix = ln[:idx] indent_prefix = ln[:idx]
result.append(ln) result.append(ln)
elif collecting: elif collecting:
if ln.startswith(indent_prefix) and "egress" not in ln and ":" not in ln.lstrip()[:20]: if (
ln.startswith(indent_prefix)
and "egress" not in ln
and ":" not in ln.lstrip()[:20]
):
result.append(ln) result.append(ln)
else: else:
break break
+3 -1
View File
@@ -124,7 +124,9 @@ class TestCleanup(unittest.TestCase):
) )
results = iter([ results = iter([
_ok(), # stop succeeds _ok(), # stop succeeds
subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="boom"), # delete fails subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="boom"
), # delete fails
_ok(), # bundle rm succeeds _ok(), # bundle rm succeeds
]) ])
@@ -11,7 +11,6 @@ import json
import sqlite3 import sqlite3
import subprocess import subprocess
import tempfile import tempfile
import threading
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
+3 -1
View File
@@ -59,7 +59,9 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl, patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl,
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg, patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
patch("bot_bottle.backend.smolmachines.prepare.agent_provision_plan") as mock_app, patch(
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
) as mock_app,
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
): ):
mock_gg.return_value.prepare.return_value = MagicMock() mock_gg.return_value.prepare.return_value = MagicMock()
+5 -1
View File
@@ -37,7 +37,11 @@ from bot_bottle.supervise import (
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(tool: str = TOOL_EGRESS_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal: def _proposal(
tool: str = TOOL_EGRESS_BLOCK,
proposed: str = "{}",
justification: str = "need a route",
) -> Proposal:
return Proposal.new( return Proposal.new(
bottle_slug="dev", bottle_slug="dev",
tool=tool, tool=tool,
@@ -7,7 +7,6 @@ which hostname will land in pipelock's allowlist on approval."""
import unittest import unittest
from bot_bottle import supervise
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
-1
View File
@@ -39,7 +39,6 @@ from bot_bottle.supervise_server import (
jsonrpc_error, jsonrpc_error,
jsonrpc_result, jsonrpc_result,
parse_jsonrpc, parse_jsonrpc,
serve,
validate_proposed_file, validate_proposed_file,
) )