Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 124e2fe1ea |
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
name: quality-eval
|
|
||||||
description: Use when the user asks to objectively evaluate, score, rate, audit, or quality-gate code, codebases, files, pull requests, or snippets using a strict 5-dimension engineering rubric with scores and refactoring steps.
|
|
||||||
metadata:
|
|
||||||
short-description: Score code quality with a strict rubric
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quality Eval
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
Act as a Staff Software Engineer and automated quality gate. Evaluate code objectively against the rubric below, surface hidden anti-patterns, and provide a mathematical grade with atomic refactoring steps.
|
|
||||||
|
|
||||||
## Evaluation Rules
|
|
||||||
|
|
||||||
- Evaluate only against the five rubric dimensions.
|
|
||||||
- Be candid. Do not inflate scores for politeness.
|
|
||||||
- Avoid generic advice. Every recommendation must name a specific code location, behavior, or pattern and include a concrete improvement direction.
|
|
||||||
- Inspect the code before scoring. For codebases, read enough representative files, tests, and architecture boundaries to justify the scope.
|
|
||||||
- When exact line numbers are available, cite them.
|
|
||||||
- Do not reveal private chain-of-thought. In the required `Chain of Thought Analysis` section, provide a concise, step-by-step audit rationale with observable findings and score justifications.
|
|
||||||
|
|
||||||
## Rubric
|
|
||||||
|
|
||||||
Score each dimension from 1 to 5 using these anchors:
|
|
||||||
|
|
||||||
| Dimension | Score 1 (Fail) | Score 3 (Pass) | Score 5 (Exemplary) |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **Architecture** | Spaghettified; tight coupling; violated separation of concerns. | Modular but relies on leaky abstractions or mixed domains. | Strict domain isolation; follows SOLID; clear dependency inversion. |
|
|
||||||
| **Readability** | Cryptic naming; deep nesting (>3 levels); widespread DRY violations. | Idiomatic but features over-complex functions or sparse documentation. | Self-documenting; expressive naming; high cohesion; flat structure. |
|
|
||||||
| **Resilience** | Swallows errors blindly; lacks contextual logging; fragile to bad input. | Basic try/catch blocks present but lacks granular, typed error handling. | Explicit error boundaries; contextual logging; structured failure modes. |
|
|
||||||
| **Testability** | Hardcoded dependencies make mocking or isolated testing impossible. | Pure functions are testable, but side-effect heavy logic lacks test hooks. | Decoupled IO; deterministic execution; structured for unit and integration tests. |
|
|
||||||
| **SecOps** | Hardcoded secrets; O(n^2) bottlenecks; zero input sanitization. | Safe from obvious flaws but lacks deep defensive optimization. | Validated inputs; optimized algorithmic complexity; zero security debt. |
|
|
||||||
|
|
||||||
## Scoring Method
|
|
||||||
|
|
||||||
1. Determine the evaluated scope and primary language.
|
|
||||||
2. Identify concrete evidence for each dimension.
|
|
||||||
3. Assign integer dimension scores from 1 to 5.
|
|
||||||
4. Compute `composite_score` as the arithmetic mean of the five dimension scores, rounded to one decimal place.
|
|
||||||
5. Include code snippets only when they make a refactoring step more actionable.
|
|
||||||
|
|
||||||
## Required Output
|
|
||||||
|
|
||||||
Structure every response into exactly these three Markdown sections:
|
|
||||||
|
|
||||||
### 1. Chain of Thought Analysis
|
|
||||||
|
|
||||||
Provide a concise step-by-step audit rationale. Name specific files, functions, patterns, anti-patterns, and rubric anchors. Keep it evidence-based and do not include hidden private reasoning.
|
|
||||||
|
|
||||||
### 2. Normalized Score Report
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"evaluation_metadata": {
|
|
||||||
"target_scope": "string",
|
|
||||||
"primary_language": "string"
|
|
||||||
},
|
|
||||||
"metrics": {
|
|
||||||
"architecture_and_modularity": 0,
|
|
||||||
"readability_and_maintainability": 0,
|
|
||||||
"error_handling_and_resilience": 0,
|
|
||||||
"testability_and_mocking": 0,
|
|
||||||
"security_and_performance": 0
|
|
||||||
},
|
|
||||||
"composite_score": 0.0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Atomic Refactoring Playbook
|
|
||||||
|
|
||||||
* **High Priority (To lift Score 1/2 to 3):**
|
|
||||||
- [ ] Actionable, specific refactoring step with file/line/context reference.
|
|
||||||
* **Medium Priority (To lift Score 3 to 4/5):**
|
|
||||||
- [ ] Optimization or architectural pattern implementation step.
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
display_name: Quality Eval
|
|
||||||
short_description: Scores code quality with a strict five-dimension rubric and refactoring playbook.
|
|
||||||
default_prompt: Evaluate this code objectively using the quality-eval rubric and return the three-section score report.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weekly canary suite. Catches upstream regressions (broken pinned
|
# Weekly canary suite. Catches upstream regressions (broken pipelock
|
||||||
# digest, etc.) without coupling every dev push to upstream registry
|
# image packaging at the pinned digest, etc.) without coupling every
|
||||||
# availability.
|
# dev push to upstream registry availability.
|
||||||
#
|
#
|
||||||
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
|
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
|
||||||
# locally with the same gating.
|
# locally with the same gating.
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
name: lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "**.py"
|
|
||||||
- ".pylintrc"
|
|
||||||
- ".gitea/workflows/lint.yml"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install dev dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run pylint
|
|
||||||
run: |
|
|
||||||
# Run pylint on all Python files in the repo
|
|
||||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
|
||||||
|
|
||||||
- name: Run pyright
|
|
||||||
run: |
|
|
||||||
# Run pyright type checking
|
|
||||||
pyright .
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Assign sequential numbers to prd-new-*.md files on merge to main.
|
|
||||||
#
|
|
||||||
# When a PR merges to main and includes prd-new-*.md files this workflow:
|
|
||||||
# 1. Finds the next available NNNN number by scanning existing PRDs.
|
|
||||||
# 2. Renames each prd-new-*.md to NNNN-<slug>.md.
|
|
||||||
# 3. Updates the title header (# PRD prd-new: → # PRD NNNN:).
|
|
||||||
# 4. Flips Status: Draft → Active when the push touched files outside
|
|
||||||
# docs/prds/ anywhere in its commit range (i.e. the implementation
|
|
||||||
# shipped together with the PRD).
|
|
||||||
# 5. Commits the renaming back to main.
|
|
||||||
#
|
|
||||||
# No-op if the working tree contains no prd-new-*.md files.
|
|
||||||
#
|
|
||||||
# NOTE: The workflow scans the working tree (not just HEAD~1..HEAD) because
|
|
||||||
# PRs land as multi-commit pushes and the prd-new file is often added in an
|
|
||||||
# earlier commit on the branch, not in the final squash/merge commit.
|
|
||||||
|
|
||||||
name: prd-number
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'docs/prds/prd-new-*.md'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
assign-numbers:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Assign PRD numbers
|
|
||||||
run: |
|
|
||||||
python3 - <<'EOF'
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
prds_dir = Path("docs/prds")
|
|
||||||
|
|
||||||
# Scan the working tree — prd-new files may have landed in any
|
|
||||||
# commit of a multi-commit push, not just HEAD.
|
|
||||||
new_prds = sorted(prds_dir.glob("prd-new-*.md"))
|
|
||||||
|
|
||||||
if not new_prds:
|
|
||||||
print("No prd-new-*.md files found — nothing to do.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Determine whether non-PRD files were also changed anywhere in
|
|
||||||
# the push range (BEFORE_SHA → HEAD). Falls back to HEAD~1 when
|
|
||||||
# the env var isn't set (e.g. local act runs).
|
|
||||||
before_sha = os.environ.get("GITHUB_EVENT_BEFORE", "HEAD~1")
|
|
||||||
all_changed = subprocess.run(
|
|
||||||
["git", "diff", "--name-only", before_sha, "HEAD"],
|
|
||||||
capture_output=True, text=True, check=True,
|
|
||||||
).stdout.splitlines()
|
|
||||||
non_prd_changed = any(
|
|
||||||
not f.startswith("docs/prds/") for f in all_changed
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find next available number.
|
|
||||||
existing = sorted(
|
|
||||||
int(m.group(1))
|
|
||||||
for p in prds_dir.glob("*.md")
|
|
||||||
if (m := re.match(r"^(\d{4})-", p.name))
|
|
||||||
)
|
|
||||||
next_num = (max(existing) + 1) if existing else 1
|
|
||||||
|
|
||||||
for prd_path in sorted(new_prds):
|
|
||||||
slug = re.sub(r"^prd-new-", "", prd_path.stem)
|
|
||||||
new_name = f"{next_num:04d}-{slug}.md"
|
|
||||||
new_path = prds_dir / new_name
|
|
||||||
print(f" {prd_path.name} → {new_name}")
|
|
||||||
|
|
||||||
content = prd_path.read_text()
|
|
||||||
|
|
||||||
# Update title header.
|
|
||||||
content = re.sub(
|
|
||||||
r"^(#\s+PRD\s+)prd-new(:)",
|
|
||||||
rf"\g<1>{next_num:04d}\2",
|
|
||||||
content,
|
|
||||||
count=1,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conditionally flip Status.
|
|
||||||
if non_prd_changed:
|
|
||||||
content = re.sub(
|
|
||||||
r"(\*\*Status:\*\*\s*)Draft",
|
|
||||||
r"\g<1>Active",
|
|
||||||
content,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
new_path.write_text(content)
|
|
||||||
subprocess.run(["git", "rm", str(prd_path)], check=True)
|
|
||||||
subprocess.run(["git", "add", str(new_path)], check=True)
|
|
||||||
next_num += 1
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["git", "commit", "-m", "ci(prd): assign sequential numbers to new PRDs"],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
subprocess.run(["git", "push"], check=True)
|
|
||||||
EOF
|
|
||||||
@@ -21,11 +21,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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: |
|
|
||||||
PYLINT_OUTPUT=$(python -m pylint bot_bottle/ 2>&1) || true
|
|
||||||
SCORE=$(echo "$PYLINT_OUTPUT" | grep -oP '(?<=rated at )\d+\.\d+/10' | head -1)
|
|
||||||
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
|
||||||
echo "Pylint score: $SCORE"
|
|
||||||
|
|
||||||
- name: Run pyright and check errors
|
|
||||||
id: pyright
|
|
||||||
run: |
|
|
||||||
PYRIGHT_OUTPUT=$(python -m pyright 2>&1) || true
|
|
||||||
ERRORS=$(echo "$PYRIGHT_OUTPUT" | grep -oP '\d+(?= error)' | head -1)
|
|
||||||
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 }}"
|
|
||||||
|
|
||||||
PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g')
|
|
||||||
|
|
||||||
if [ -n "$PYLINT_SCORE_ENCODED" ]; then
|
|
||||||
sed -i "s|/badge/pylint-[^)]*|/badge/pylint-${PYLINT_SCORE_ENCODED}-brightgreen|" README.md
|
|
||||||
fi
|
|
||||||
if [ -n "$PYRIGHT_ERRORS" ]; then
|
|
||||||
sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n\n'"[skip ci]"
|
|
||||||
git commit -m "$MSG"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
[MAIN]
|
|
||||||
|
|
||||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
|
||||||
# 3 compatible code, which means that the block might have code that exists
|
|
||||||
# only in one or another interpreter, leading to false positives when analysed.
|
|
||||||
analyse-fallback-blocks=no
|
|
||||||
|
|
||||||
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
|
|
||||||
# in a server-like mode.
|
|
||||||
clear-cache-post-run=no
|
|
||||||
|
|
||||||
# Load and enable all available extensions. Use --list-extensions to see a list
|
|
||||||
# all available extensions.
|
|
||||||
#enable-all-extensions=
|
|
||||||
|
|
||||||
# In error mode, messages with a category besides ERROR or FATAL are
|
|
||||||
# suppressed, and no reports are done by default. Error mode is compatible with
|
|
||||||
# disabling specific errors.
|
|
||||||
#errors-only=
|
|
||||||
|
|
||||||
# Always return a 0 (non-error) status code, even if lint errors are found.
|
|
||||||
# This is primarily useful in continuous integration scripts.
|
|
||||||
#exit-zero=
|
|
||||||
|
|
||||||
# A comma-separated list of package or module names from where C extensions may
|
|
||||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
|
||||||
# run arbitrary code.
|
|
||||||
extension-pkg-allow-list=
|
|
||||||
|
|
||||||
# A comma-separated list of package or module names from where C extensions may
|
|
||||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
|
||||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
|
||||||
# for backward compatibility.)
|
|
||||||
extension-pkg-whitelist=
|
|
||||||
|
|
||||||
# Return non-zero exit code if any of these messages/categories are detected,
|
|
||||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
|
||||||
# specified are enabled, while categories only check already-enabled messages.
|
|
||||||
fail-on=
|
|
||||||
|
|
||||||
# Specify a score threshold under which the program will exit with error.
|
|
||||||
fail-under=10
|
|
||||||
|
|
||||||
# Interpret the stdin as a python script, whose filename needs to be passed as
|
|
||||||
# the module_or_package argument.
|
|
||||||
#from-stdin=
|
|
||||||
|
|
||||||
# Files or directories to be skipped. They should be base names, not paths.
|
|
||||||
ignore=CVS
|
|
||||||
|
|
||||||
# Add files or directories matching the regular expressions patterns to the
|
|
||||||
# ignore-list. The regex matches against paths and can be in Posix or Windows
|
|
||||||
# format. Because '\\' represents the directory delimiter on Windows systems,
|
|
||||||
# it can't be used as an escape character.
|
|
||||||
ignore-paths=
|
|
||||||
|
|
||||||
# Files or directories matching the regular expression patterns are skipped.
|
|
||||||
# The regex matches against base names, not paths. The default value ignores
|
|
||||||
# Emacs file locks
|
|
||||||
ignore-patterns=^\.#
|
|
||||||
|
|
||||||
# List of module names for which member attributes should not be checked and
|
|
||||||
# will not be imported (useful for modules/projects where namespaces are
|
|
||||||
# manipulated during runtime and thus existing member attributes cannot be
|
|
||||||
# deduced by static analysis). It supports qualified module names, as well as
|
|
||||||
# Unix pattern matching.
|
|
||||||
ignored-modules=
|
|
||||||
|
|
||||||
# Python code to execute, usually for sys.path manipulation such as
|
|
||||||
# pygtk.require().
|
|
||||||
#init-hook=
|
|
||||||
|
|
||||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
|
||||||
# number of processors available to use, and will cap the count on Windows to
|
|
||||||
# avoid hangs.
|
|
||||||
jobs=1
|
|
||||||
|
|
||||||
# Control the amount of potential inferred values when inferring a single
|
|
||||||
# object. This can help the performance when dealing with large functions or
|
|
||||||
# complex, nested conditions.
|
|
||||||
limit-inference-results=100
|
|
||||||
|
|
||||||
# List of plugins (as comma separated values of python module names) to load,
|
|
||||||
# usually to register additional checkers.
|
|
||||||
load-plugins=
|
|
||||||
|
|
||||||
# Pickle collected data for later comparisons.
|
|
||||||
persistent=yes
|
|
||||||
|
|
||||||
# Resolve imports to .pyi stubs if available. May reduce no-member messages and
|
|
||||||
# increase not-an-iterable messages.
|
|
||||||
prefer-stubs=no
|
|
||||||
|
|
||||||
# Minimum Python version to use for version dependent checks. Will default to
|
|
||||||
# the version used to run pylint.
|
|
||||||
py-version=3.14
|
|
||||||
|
|
||||||
# Discover python modules and packages in the file system subtree.
|
|
||||||
recursive=no
|
|
||||||
|
|
||||||
# Add paths to the list of the source roots. Supports globbing patterns. The
|
|
||||||
# source root is an absolute path or a path relative to the current working
|
|
||||||
# directory used to determine a package namespace for modules located under the
|
|
||||||
# source root.
|
|
||||||
source-roots=
|
|
||||||
|
|
||||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
|
||||||
# active Python interpreter and may run arbitrary code.
|
|
||||||
unsafe-load-any-extension=no
|
|
||||||
|
|
||||||
# In verbose mode, extra non-checker-related info will be displayed.
|
|
||||||
#verbose=
|
|
||||||
|
|
||||||
|
|
||||||
[BASIC]
|
|
||||||
|
|
||||||
# Naming style matching correct argument names.
|
|
||||||
argument-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct argument names. Overrides argument-
|
|
||||||
# naming-style. If left empty, argument names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#argument-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct attribute names.
|
|
||||||
attr-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
|
||||||
# style. If left empty, attribute names will be checked with the set naming
|
|
||||||
# style.
|
|
||||||
#attr-rgx=
|
|
||||||
|
|
||||||
# Bad variable names which should always be refused, separated by a comma.
|
|
||||||
bad-names=foo,
|
|
||||||
bar,
|
|
||||||
baz,
|
|
||||||
toto,
|
|
||||||
tutu,
|
|
||||||
tata
|
|
||||||
|
|
||||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be refused
|
|
||||||
bad-names-rgxs=
|
|
||||||
|
|
||||||
# Naming style matching correct class attribute names.
|
|
||||||
class-attribute-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct class attribute names. Overrides class-
|
|
||||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
|
||||||
# with the set naming style.
|
|
||||||
#class-attribute-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct class constant names.
|
|
||||||
class-const-naming-style=UPPER_CASE
|
|
||||||
|
|
||||||
# Regular expression matching correct class constant names. Overrides class-
|
|
||||||
# const-naming-style. If left empty, class constant names will be checked with
|
|
||||||
# the set naming style.
|
|
||||||
#class-const-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct class names.
|
|
||||||
class-naming-style=PascalCase
|
|
||||||
|
|
||||||
# Regular expression matching correct class names. Overrides class-naming-
|
|
||||||
# style. If left empty, class names will be checked with the set naming style.
|
|
||||||
#class-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct constant names.
|
|
||||||
const-naming-style=UPPER_CASE
|
|
||||||
|
|
||||||
# Regular expression matching correct constant names. Overrides const-naming-
|
|
||||||
# style. If left empty, constant names will be checked with the set naming
|
|
||||||
# style.
|
|
||||||
#const-rgx=
|
|
||||||
|
|
||||||
# Minimum line length for functions/classes that require docstrings, shorter
|
|
||||||
# ones are exempt.
|
|
||||||
docstring-min-length=-1
|
|
||||||
|
|
||||||
# Naming style matching correct function names.
|
|
||||||
function-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct function names. Overrides function-
|
|
||||||
# naming-style. If left empty, function names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#function-rgx=
|
|
||||||
|
|
||||||
# Good variable names which should always be accepted, separated by a comma.
|
|
||||||
good-names=i,
|
|
||||||
j,
|
|
||||||
k,
|
|
||||||
ex,
|
|
||||||
Run,
|
|
||||||
_
|
|
||||||
|
|
||||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be accepted
|
|
||||||
good-names-rgxs=
|
|
||||||
|
|
||||||
# Include a hint for the correct naming format with invalid-name.
|
|
||||||
include-naming-hint=no
|
|
||||||
|
|
||||||
# Naming style matching correct inline iteration names.
|
|
||||||
inlinevar-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct inline iteration names. Overrides
|
|
||||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
|
||||||
# with the set naming style.
|
|
||||||
#inlinevar-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct method names.
|
|
||||||
method-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct method names. Overrides method-naming-
|
|
||||||
# style. If left empty, method names will be checked with the set naming style.
|
|
||||||
#method-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct module names.
|
|
||||||
module-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct module names. Overrides module-naming-
|
|
||||||
# style. If left empty, module names will be checked with the set naming style.
|
|
||||||
#module-rgx=
|
|
||||||
|
|
||||||
# Colon-delimited sets of names that determine each other's naming style when
|
|
||||||
# the name regexes allow several styles.
|
|
||||||
name-group=
|
|
||||||
|
|
||||||
# Regular expression which should only match function or class names that do
|
|
||||||
# not require a docstring.
|
|
||||||
no-docstring-rgx=^_
|
|
||||||
|
|
||||||
# Regular expression matching correct parameter specification variable names.
|
|
||||||
# If left empty, parameter specification variable names will be checked with
|
|
||||||
# the set naming style.
|
|
||||||
#paramspec-rgx=
|
|
||||||
|
|
||||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
|
||||||
# to this list to register other decorators that produce valid properties.
|
|
||||||
# These decorators are taken in consideration only for invalid-name.
|
|
||||||
property-classes=abc.abstractproperty
|
|
||||||
|
|
||||||
# Regular expression matching correct type alias names. If left empty, type
|
|
||||||
# alias names will be checked with the set naming style.
|
|
||||||
#typealias-rgx=
|
|
||||||
|
|
||||||
# Regular expression matching correct type variable names. If left empty, type
|
|
||||||
# variable names will be checked with the set naming style.
|
|
||||||
#typevar-rgx=
|
|
||||||
|
|
||||||
# Regular expression matching correct type variable tuple names. If left empty,
|
|
||||||
# type variable tuple names will be checked with the set naming style.
|
|
||||||
#typevartuple-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct variable names.
|
|
||||||
variable-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct variable names. Overrides variable-
|
|
||||||
# naming-style. If left empty, variable names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#variable-rgx=
|
|
||||||
|
|
||||||
|
|
||||||
[CLASSES]
|
|
||||||
|
|
||||||
# Warn about protected attribute access inside special methods
|
|
||||||
check-protected-access-in-special-methods=no
|
|
||||||
|
|
||||||
# List of method names used to declare (i.e. assign) instance attributes.
|
|
||||||
defining-attr-methods=__init__,
|
|
||||||
__new__,
|
|
||||||
setUp,
|
|
||||||
asyncSetUp,
|
|
||||||
__post_init__
|
|
||||||
|
|
||||||
# List of member names, which should be excluded from the protected access
|
|
||||||
# warning.
|
|
||||||
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a class method.
|
|
||||||
valid-classmethod-first-arg=cls
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a metaclass class method.
|
|
||||||
valid-metaclass-classmethod-first-arg=mcs
|
|
||||||
|
|
||||||
|
|
||||||
[DESIGN]
|
|
||||||
|
|
||||||
# List of regular expressions of class ancestor names to ignore when counting
|
|
||||||
# public methods (see R0903)
|
|
||||||
exclude-too-few-public-methods=
|
|
||||||
|
|
||||||
# List of qualified class names to ignore when counting class parents (see
|
|
||||||
# R0901)
|
|
||||||
ignored-parents=
|
|
||||||
|
|
||||||
# Maximum number of arguments for function / method.
|
|
||||||
max-args=5
|
|
||||||
|
|
||||||
# Maximum number of attributes for a class (see R0902).
|
|
||||||
max-attributes=7
|
|
||||||
|
|
||||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
|
||||||
max-bool-expr=5
|
|
||||||
|
|
||||||
# Maximum number of branch for function / method body.
|
|
||||||
max-branches=12
|
|
||||||
|
|
||||||
# Maximum number of locals for function / method body.
|
|
||||||
max-locals=15
|
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
|
||||||
max-parents=7
|
|
||||||
|
|
||||||
# Maximum number of positional arguments for function / method.
|
|
||||||
max-positional-arguments=5
|
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
|
||||||
max-public-methods=20
|
|
||||||
|
|
||||||
# Maximum number of return / yield for function / method body.
|
|
||||||
max-returns=6
|
|
||||||
|
|
||||||
# Maximum number of statements in function / method body.
|
|
||||||
max-statements=50
|
|
||||||
|
|
||||||
# Minimum number of public methods for a class (see R0903).
|
|
||||||
min-public-methods=2
|
|
||||||
|
|
||||||
|
|
||||||
[EXCEPTIONS]
|
|
||||||
|
|
||||||
# Exceptions that will emit a warning when caught.
|
|
||||||
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
|
||||||
|
|
||||||
|
|
||||||
[FORMAT]
|
|
||||||
|
|
||||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
|
||||||
expected-line-ending-format=
|
|
||||||
|
|
||||||
# Regexp for a line that is allowed to be longer than the limit.
|
|
||||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
|
||||||
|
|
||||||
# Number of spaces of indent required inside a hanging or continued line.
|
|
||||||
indent-after-paren=4
|
|
||||||
|
|
||||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
|
||||||
# tab).
|
|
||||||
indent-string=' '
|
|
||||||
|
|
||||||
# Maximum number of characters on a single line. Pylint's default of 100 is
|
|
||||||
# based on PEP 8's guidance that teams may choose line lengths up to 99
|
|
||||||
# characters.
|
|
||||||
max-line-length=100
|
|
||||||
|
|
||||||
# Maximum number of lines in a module.
|
|
||||||
max-module-lines=1000
|
|
||||||
|
|
||||||
# Allow the body of a class to be on the same line as the declaration if body
|
|
||||||
# contains single statement.
|
|
||||||
single-line-class-stmt=no
|
|
||||||
|
|
||||||
# Allow the body of an if to be on the same line as the test if there is no
|
|
||||||
# else.
|
|
||||||
single-line-if-stmt=no
|
|
||||||
|
|
||||||
|
|
||||||
[LOGGING]
|
|
||||||
|
|
||||||
# The type of string formatting that logging methods do. `old` means using %
|
|
||||||
# formatting, `new` is for `{}` formatting.
|
|
||||||
logging-format-style=old
|
|
||||||
|
|
||||||
# Logging modules to check that the string format arguments are in logging
|
|
||||||
# function parameter format.
|
|
||||||
logging-modules=logging
|
|
||||||
|
|
||||||
|
|
||||||
[MESSAGES CONTROL]
|
|
||||||
|
|
||||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
|
||||||
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
|
|
||||||
# UNDEFINED.
|
|
||||||
confidence=HIGH,
|
|
||||||
CONTROL_FLOW,
|
|
||||||
INFERENCE,
|
|
||||||
INFERENCE_FAILURE,
|
|
||||||
UNDEFINED
|
|
||||||
|
|
||||||
# Disable the message, report, category or checker with the given id(s). You
|
|
||||||
# can either give multiple identifiers separated by comma (,) or put this
|
|
||||||
# option multiple times (only on the command line, not in the configuration
|
|
||||||
# file where it should appear only once). You can also use "--disable=all" to
|
|
||||||
# disable everything first and then re-enable specific checks. For example, if
|
|
||||||
# you want to run only the similarities checker, you can use "--disable=all
|
|
||||||
# --enable=similarities". If you want to run only the classes checker, but have
|
|
||||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
|
||||||
# --disable=W".
|
|
||||||
disable=raw-checker-failed,
|
|
||||||
bad-inline-option,
|
|
||||||
locally-disabled,
|
|
||||||
file-ignored,
|
|
||||||
suppressed-message,
|
|
||||||
useless-suppression,
|
|
||||||
deprecated-pragma,
|
|
||||||
use-symbolic-message-instead,
|
|
||||||
use-implicit-booleaness-not-comparison-to-string,
|
|
||||||
use-implicit-booleaness-not-comparison-to-zero,
|
|
||||||
missing-function-docstring,
|
|
||||||
missing-class-docstring,
|
|
||||||
missing-module-docstring,
|
|
||||||
invalid-name,
|
|
||||||
cyclic-import,
|
|
||||||
too-many-arguments,
|
|
||||||
too-many-locals,
|
|
||||||
too-many-branches,
|
|
||||||
too-many-statements,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
duplicate-code,
|
|
||||||
import-outside-toplevel,
|
|
||||||
too-few-public-methods,
|
|
||||||
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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# bot-bottle
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
bot-bottle spins up an isolated backend runtime for running AI coding agents
|
|
||||||
with a curated set of skills and env vars. The point is to run agents with
|
|
||||||
broad permissions inside a sandbox, so a misbehaving agent cannot reach the
|
|
||||||
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
|
||||||
the runtime lifecycle and the copying of skills and env vars into it.
|
|
||||||
The default backend on compatible macOS hosts is macos-container:
|
|
||||||
agents and sidecar bundles run through Apple's `container` CLI without
|
|
||||||
requiring Docker. The smolmachines backend remains available with
|
|
||||||
`BOT_BOTTLE_BACKEND=smolmachines` or `--backend=smolmachines`; agents
|
|
||||||
run in a libkrun micro-VM, while the sidecar bundle still uses Docker.
|
|
||||||
The legacy Docker backend remains available with `BOT_BOTTLE_BACKEND=docker`
|
|
||||||
or `--backend=docker`.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Minimize risk of running agents with full permissions
|
|
||||||
- Allow me to easily spin up agent tasks in parallel
|
|
||||||
- Create isolated, well defined, easily updated, shareable agents
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Communicating between agents directly
|
|
||||||
- Removing the Docker backend
|
|
||||||
- Advanced agent auditing (lean on git history for auditing)
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
- `README.md` — short public-facing description.
|
|
||||||
- `AGENTS.md` — this file, orientation for future agent sessions.
|
|
||||||
- `.gitignore` — OS junk.
|
|
||||||
- `.bot-bottle/` — per-repo agent and bottle manifests (YAML markdown format).
|
|
||||||
- `examples/` — example bottles and agents showing the manifest format.
|
|
||||||
- `docs/README.md` — docs overview; when to write which document.
|
|
||||||
- `docs/prds/` — product requirement docs (see `docs/prds/README.md` for format).
|
|
||||||
- `docs/research/` — research notes (see `docs/research/README.md`).
|
|
||||||
- `docs/decisions/` — decision records (ADR-lite).
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Three kinds of doc, each with its own conventions in-folder; see
|
|
||||||
`docs/README.md` for when to write which:
|
|
||||||
- **PRDs** (`docs/prds/`) — one feature per file. While a PR is open
|
|
||||||
the file is named `prd-new-<kebab>.md`; CI assigns a sequential
|
|
||||||
number on merge to `main` and renames it. A `Status:` line tracks
|
|
||||||
lifecycle: Draft → Active (shipped to `main`) →
|
|
||||||
Superseded/Retargeted. Format in `docs/prds/README.md`.
|
|
||||||
- **Research notes** (`docs/research/`) — opinionated investigations;
|
|
||||||
unnumbered kebab-case, freeform and verdict-first. See
|
|
||||||
`docs/research/README.md`.
|
|
||||||
- **Decision records** (`docs/decisions/`) — ADR-lite, numbered
|
|
||||||
`NNNN-kebab.md`, for policies and non-feature decisions. See
|
|
||||||
`docs/decisions/README.md`.
|
|
||||||
- Keep decision rationale self-contained in the repo, not in Gitea
|
|
||||||
issue threads. Issues are an ephemeral inbox; the durable "why" lives
|
|
||||||
in a PRD, research note, or decision record.
|
|
||||||
- Low dependencies by default. The project is Python, stdlib-first (no
|
|
||||||
runtime pip dependencies in the package itself; the only language
|
|
||||||
runtime is the Python 3.13 used by the CLI + sidecars). Ask before
|
|
||||||
adding new tools, runtimes, or package managers.
|
|
||||||
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
|
||||||
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
|
|
||||||
`docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
|
|
||||||
A `commit-msg` hook in `.githooks/` enforces this. Activate it once per clone
|
|
||||||
with `git config core.hooksPath .githooks`.
|
|
||||||
|
|
||||||
## When you're unsure
|
|
||||||
|
|
||||||
Ask. Default to drafting in chat over editing files when the request is ambiguous.
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# claude-bottle
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
claude-bottle spins up an isolated container for running Claude Code with a
|
||||||
|
curated set of skills and env vars. The point is to run Claude with broad
|
||||||
|
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
||||||
|
Bash scripts orchestrate the container lifecycle and the copying of skills
|
||||||
|
and env vars into it.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Minimize risk of running claude with full permissions
|
||||||
|
- Allow me to easily spin up agent tasks in parallel
|
||||||
|
- Create isolated, well defined, easily updated, shareable agents
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Communicating between agents directly
|
||||||
|
- Self hosted VMs (v1 uses local Docker containers, not VMs)
|
||||||
|
- Advanced agent auditing (lean on git history for auditing)
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `README.md` — short public-facing description.
|
||||||
|
- `CLAUDE.md` — this file, orientation for future Claude sessions.
|
||||||
|
- `.gitignore` — OS junk.
|
||||||
|
- `claude-bottle.json` — manifest of named agents (env / skills / prompt
|
||||||
|
per agent), consumed by `cli.py`. See "Manifest" under
|
||||||
|
"Intended design".
|
||||||
|
- `docs/INDEX.md` — pointer to the research notes.
|
||||||
|
- `docs/prds/` — product requirement docs.
|
||||||
|
- `docs/research/` — research notes (empty for now, kept tracked via `.gitkeep`).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Product requirement docs live in `docs/prds/`.
|
||||||
|
- Research notes live in `docs/research/`.
|
||||||
|
- Low dependencies by default. The project is bash-first; ask before adding new
|
||||||
|
tools, runtimes, or package managers.
|
||||||
|
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||||
|
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
|
||||||
|
`docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
|
||||||
|
A `commit-msg` hook in `.githooks/` enforces this. Activate it once per clone
|
||||||
|
with `git config core.hooksPath .githooks`.
|
||||||
|
|
||||||
|
## When you're unsure
|
||||||
|
|
||||||
|
Ask. Default to drafting in chat over editing files when the request is ambiguous.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# bot-bottle container image.
|
# claude-bottle container image.
|
||||||
#
|
#
|
||||||
# Goal: a small, cache-friendly base that ships claude-code (the
|
# Goal: a small, cache-friendly base that ships claude-code (the
|
||||||
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
||||||
@@ -16,27 +16,21 @@ FROM node:22-slim
|
|||||||
# features (status checks, commits, PR creation) — without git in the
|
# features (status checks, commits, PR creation) — without git in the
|
||||||
# image, those features fail in surprising ways once the user does any
|
# image, those features fail in surprising ways once the user does any
|
||||||
# real work. ca-certificates is already in the slim base; listed for
|
# real work. ca-certificates is already in the slim base; listed for
|
||||||
# clarity in case the base ever drops it. curl is here so any
|
# clarity in case the base ever drops it. socat is the privileged
|
||||||
# HTTPS_PROXY-aware tool (curl itself, plus anything that shells out
|
# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent
|
||||||
# to it) works against egress's bumped TLS without the agent needing
|
# runs as root and rejects non-root connections, so socat sits between
|
||||||
# local DNS.
|
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
|
||||||
|
# tool (curl itself, plus anything that shells out to it) works
|
||||||
|
# against pipelock's bumped TLS without the agent needing local DNS.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git ca-certificates curl \
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by claude-code itself
|
|
||||||
# (claude-code is a Node CLI), but is convenient for the agent to
|
|
||||||
# shell out to for ad-hoc scripts. Kept on its own layer so it can
|
|
||||||
# be moved to a downstream image if the base ever needs to shrink.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install claude-code globally. Pinned to the version verified in the v1
|
# Install claude-code globally. Pinned to the version verified in the v1
|
||||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||||
# up new behavior.
|
# up new behavior.
|
||||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Run as a non-root user. The node image already provides a `node` user
|
# Run as a non-root user. The node image already provides a `node` user
|
||||||
@@ -46,7 +40,7 @@ USER node
|
|||||||
WORKDIR /home/node
|
WORKDIR /home/node
|
||||||
|
|
||||||
# Pre-create the skills directory so PRD 0002's host->container skill
|
# Pre-create the skills directory so PRD 0002's host->container skill
|
||||||
# copier (bot_bottle/skills.py) drops files into a path owned by the
|
# copier (claude_bottle/skills.py) drops files into a path owned by the
|
||||||
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
|
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
|
||||||
# baking it into the image avoids a permission-confusion footgun if a
|
# baking it into the image avoids a permission-confusion footgun if a
|
||||||
# future change to the launcher copies in as a different user.
|
# future change to the launcher copies in as a different user.
|
||||||
@@ -66,7 +60,7 @@ RUN cat > "$HOME/.claude.json" <<JSON
|
|||||||
JSON
|
JSON
|
||||||
|
|
||||||
# Default to an interactive claude session. In the v1 launcher,
|
# Default to an interactive claude session. In the v1 launcher,
|
||||||
# `bot_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
# `claude_bottle/cli/start.py` runs the container detached and uses `docker exec`
|
||||||
# to attach a TTY, but this CMD makes `docker run -it bot-bottle-claude` also
|
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
|
||||||
# do something useful for ad-hoc debugging.
|
# do something useful for ad-hoc debugging.
|
||||||
CMD ["claude"]
|
CMD ["claude"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Per-bottle cred-proxy sidecar image (PRD 0010).
|
||||||
|
#
|
||||||
|
# Holds API tokens (Anthropic OAuth, GitHub PAT, Gitea PAT, npm) in
|
||||||
|
# this container's environ, strips inbound Authorization headers, and
|
||||||
|
# injects the configured one before forwarding to the real upstream
|
||||||
|
# over HTTPS. The agent's environ carries only URLs pointing at this
|
||||||
|
# sidecar — the upstream credentials never reach the agent container.
|
||||||
|
#
|
||||||
|
# Stdlib-only Python; no pip install layer. The route table lands at
|
||||||
|
# /run/cred-proxy/routes.json via `docker cp` from the backend's
|
||||||
|
# start step.
|
||||||
|
|
||||||
|
# python:3.13-alpine. Pinned by digest for reproducibility — the
|
||||||
|
# proxy script is stdlib-only so a Python minor-version drift would
|
||||||
|
# only affect the runtime, not API surface, but pinning makes the
|
||||||
|
# image bytes deterministic.
|
||||||
|
FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc
|
||||||
|
|
||||||
|
# `ca-certificates` ships /usr/sbin/update-ca-certificates and the
|
||||||
|
# system trust store. The backend's start step `docker cp`s the
|
||||||
|
# per-bottle pipelock CA into /usr/local/share/ca-certificates/ so
|
||||||
|
# the entrypoint's update-ca-certificates picks it up — cred-proxy's
|
||||||
|
# outbound HTTPS then trusts pipelock's bumped certs and outbound
|
||||||
|
# traffic routes through pipelock (HTTPS_PROXY in the environ).
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
# The proxy script ships as a single file. Tests in tests/unit/ import
|
||||||
|
# it as `claude_bottle.cred_proxy_server`; the container runs it
|
||||||
|
# directly as a script. No package install, no other modules pulled.
|
||||||
|
COPY claude_bottle/cred_proxy_server.py /app/cred_proxy_server.py
|
||||||
|
|
||||||
|
# Pre-create the runtime directory the backend's start step will
|
||||||
|
# `docker cp` routes.json into. docker cp does not create
|
||||||
|
# intermediate dirs, so the mkdir must be baked into the image.
|
||||||
|
RUN mkdir -p /run/cred-proxy
|
||||||
|
|
||||||
|
# Listening port. The agent's environ resolves the cred-proxy host
|
||||||
|
# via Docker's embedded DNS on the per-bottle internal network and
|
||||||
|
# dials this port. Surfaced as EXPOSE for documentation; not required
|
||||||
|
# for the internal network to route to it.
|
||||||
|
EXPOSE 9099
|
||||||
|
|
||||||
|
# Entry runs update-ca-certificates so the per-bottle pipelock CA
|
||||||
|
# docker-cp'd by the backend's start step is folded into
|
||||||
|
# /etc/ssl/certs/ca-certificates.crt before python comes up. Then
|
||||||
|
# exec into the server so PID 1 is python (clean signal handling
|
||||||
|
# and exit codes). Output of update-ca-certificates is silenced —
|
||||||
|
# the entry script prints one line per cert under normal operation,
|
||||||
|
# which the test suite would otherwise treat as a log smell.
|
||||||
|
ENTRYPOINT ["sh", "-c", "update-ca-certificates >/dev/null 2>&1 && exec python3 /app/cred_proxy_server.py"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Per-agent git-gate sidecar image (PRD 0008).
|
||||||
|
#
|
||||||
|
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
|
||||||
|
# can push to it over git://. A shared pre-receive hook runs gitleaks
|
||||||
|
# against each incoming ref; on clean, it forwards the ref to the real
|
||||||
|
# upstream using a credential the gate holds. The agent never sees the
|
||||||
|
# upstream credential.
|
||||||
|
#
|
||||||
|
# The agent-facing leg sits on a Docker --internal network with no
|
||||||
|
# default route, so the image is fully self-contained: no apk pulls at
|
||||||
|
# boot, no remote registry lookups during the entrypoint.
|
||||||
|
|
||||||
|
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
|
||||||
|
# alpine doesn't package gitleaks so this avoids a separate
|
||||||
|
# install path. Pinned by digest for reproducibility.
|
||||||
|
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
|
||||||
|
|
||||||
|
# openssh-client supplies the upstream SSH transport the pre-receive
|
||||||
|
# hook uses to forward accepted refs. git-daemon is the listener the
|
||||||
|
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
|
||||||
|
# part of `git`). The `git` core binary is already in the base image.
|
||||||
|
RUN apk add --no-cache openssh-client git-daemon
|
||||||
|
|
||||||
|
# Layout the gate uses at runtime:
|
||||||
|
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
||||||
|
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
|
||||||
|
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
|
||||||
|
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
|
||||||
|
# /git/<name>.git — bare repos, created by the entrypoint
|
||||||
|
#
|
||||||
|
# The intermediate directories must exist before `docker cp` runs (cp
|
||||||
|
# does not create them); the bare-repo parent (/git) is also pre-created
|
||||||
|
# defensively.
|
||||||
|
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
||||||
|
|
||||||
|
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
|
||||||
|
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Per-bottle sidecar bundle image (PRD 0024).
|
|
||||||
#
|
|
||||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
|
||||||
# supervise) into one. A small stdlib-Python init supervisor at
|
|
||||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
|
||||||
# propagates per-daemon stdout/stderr to the container log with a
|
|
||||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
|
||||||
#
|
|
||||||
# Layout:
|
|
||||||
#
|
|
||||||
# /usr/bin/gitleaks gitleaks binary
|
|
||||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
|
||||||
# /app/supervise_server.py + .py supervise MCP server
|
|
||||||
# /app/sidecar_init.py PID 1 supervisor
|
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
|
||||||
# /git/* bare repos, populated at runtime
|
|
||||||
# /run/supervise/queue/ bind-mounted at run time
|
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
|
||||||
#
|
|
||||||
# Exposed ports inside the container:
|
|
||||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
|
||||||
# 9418 git-gate (git-daemon)
|
|
||||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
|
||||||
# 9100 supervise (MCP HTTP)
|
|
||||||
|
|
||||||
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
|
||||||
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
|
||||||
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
|
||||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
|
||||||
|
|
||||||
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
|
||||||
# Python + mitmdump pre-installed — heavier than the others, so
|
|
||||||
# this stage starts there and pulls the standalone binaries in.
|
|
||||||
FROM mitmproxy/mitmproxy:11.1.3
|
|
||||||
|
|
||||||
# Run as root inside the bundle. The bundle is the isolation
|
|
||||||
# boundary; per-daemon user separation inside it is not load-bearing
|
|
||||||
# and complicates the supervisor's spawn path.
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Runtime system deps:
|
|
||||||
# git supplies the `git daemon` subcommand (no separate package)
|
|
||||||
# plus the core `git` binary the pre-receive hook invokes.
|
|
||||||
# openssh-client supplies the upstream SSH transport the
|
|
||||||
# pre-receive hook uses to forward accepted refs.
|
|
||||||
# ca-certificates is needed for mitmdump upstream TLS (the
|
|
||||||
# base image already has it; listed for explicitness).
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
git openssh-client ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Pull the standalone binaries into the final image.
|
|
||||||
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|
||||||
|
|
||||||
# Project Python: addon + server modules + the init supervisor.
|
|
||||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
|
||||||
# top-level siblings (absolute imports), matching the prior
|
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
|
||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
|
||||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
|
||||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
|
||||||
|
|
||||||
# Pre-create runtime directories the compose renderer + start
|
|
||||||
# step expect to exist. `docker cp` does not create intermediate
|
|
||||||
# dirs, and bind mounts won't either if the parent is missing.
|
|
||||||
RUN mkdir -p \
|
|
||||||
/etc/egress \
|
|
||||||
/etc/git-gate \
|
|
||||||
/git-gate/creds \
|
|
||||||
/git \
|
|
||||||
/run/supervise/queue \
|
|
||||||
/home/mitmproxy/.mitmproxy
|
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
|
||||||
# subset the bottle uses.
|
|
||||||
EXPOSE 8888 9099 9418 9420 9100
|
|
||||||
|
|
||||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
|
||||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
|
||||||
# propagation; no `exec` chain in the entrypoint itself.
|
|
||||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
|
||||||
@@ -1,56 +1,84 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/logo.svg" alt="bot-bottle logo" width="140">
|
<img src="docs/logo.svg" alt="claude-bottle logo" width="140">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# bot-bottle
|
# claude-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/claude-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
|
||||||
[](https://github.com/microsoft/pyright)
|
|
||||||
|
|
||||||
**Run any coding agent like it might be compromised — and lose nothing when it is.**
|
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
|
||||||
|
|
||||||
bot-bottle is a provider-neutral, security-first substrate for autonomous agents. Bring Claude Code, Codex, or your own harness; each one runs in an ephemeral, per-agent "bottle" it cannot modify, where every byte of egress is scanned for exfiltration and capabilities are narrowed to exactly what the task declares.
|

|
||||||
|
|
||||||
**Problem:** You want to let a coding agent run unsupervised, but a prompt-injected or misbehaving agent — or a poisoned repo, MCP server, or skill — can wreck your environment or exfiltrate your secrets. Locking yourself to one vendor's cloud doesn't fix that; it just moves the blast radius.
|
Four prompts to the agent inside a real bottle:
|
||||||
|
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`.
|
||||||
|
|
||||||
**Solution:** A neutral control plane that runs *whatever agent you choose* inside an isolation boundary the agent can't touch: TLS-bumped egress allowlisting, outbound/inbound DLP, gitleaks-gated pushes, and host secrets the agent never sees. Swap the agent; keep the guarantees.
|
## Why "claude-bottle"?
|
||||||
|
|
||||||
## Why bot-bottle
|
Each container is a bottle; Claude is the genie inside. The genie's
|
||||||
|
powers are exactly what the manifest grants it — a specific set of
|
||||||
|
skills, a specific set of secrets, and a specific set of hosts it can
|
||||||
|
reach — nothing more. You uncork one bottle per agent
|
||||||
|
(`./cli.py start <agent>`), many bottles run in parallel, and each is
|
||||||
|
scoped to its task. When the session ends the bottle is destroyed and
|
||||||
|
the genie does not persist.
|
||||||
|
|
||||||
### A neutral substrate — bring your own agent
|
## Goals
|
||||||
|
|
||||||
- **Provider-agnostic by design** — Claude and Codex ship built in; any other agent (Gemini, Aider, a local-model wrapper) is a drop-in plugin at `~/.bot-bottle/contrib/<name>/` — no fork, no PR against this repo. The manifest accepts any provider template, and the isolation, egress, and git guarantees are identical across all of them.
|
- Scope each agent to the minimum credentials and network egress its task actually needs
|
||||||
- **One control plane, every harness** — the same bottle, egress policy, and supervise flow wrap whichever agent you run, so switching or mixing providers doesn't change your security posture.
|
- Run multiple agents in parallel, isolated from each other
|
||||||
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
- Keep code, credentials, and agent activity on infrastructure I control — no third-party agent runtime
|
||||||
|
|
||||||
### An isolation boundary the agent can't touch
|
## Security model
|
||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
Each agent runs in its own bottle: its own container, its own internal
|
||||||
- **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.
|
Docker network, and its own pipelock sidecar. Bottles don't share
|
||||||
- **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.
|
state, don't talk to each other, and only get the env vars, skills,
|
||||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
SSH identities, and egress hosts the manifest grants them — nothing
|
||||||
- **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.
|
more. Any one agent only has the access it needs to do its job.
|
||||||
|
|
||||||
### Isolation that matches your host
|
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, claude-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`.
|
||||||
|
|
||||||
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
The egress proxy and OAuth-token handling below are the load-bearing
|
||||||
- **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.
|
pieces of v1.
|
||||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
|
||||||
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
|
|
||||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
|
||||||
|
|
||||||
Per-provider auth (Claude long-lived OAuth token; Codex opt-in host device-auth forwarding) and per-provider images (`Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile) are configured on the bottle — see [Manifest](#manifest).
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
A bottle is the agent container plus up to three per-protocol egress
|
||||||
|
sidecars on a per-agent Docker `--internal` network. The agent has no
|
||||||
On the smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS.
|
default route off-box. All HTTP and HTTPS egress — from the agent
|
||||||
|
*and* from cred-proxy when it dials an upstream — funnels through
|
||||||
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
pipelock, where the egress allowlist, TLS interception, and
|
||||||
|
request-body DLP scanner enforce the manifest before any byte leaves
|
||||||
The Docker topology looks like this:
|
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.
|
||||||
|
|
||||||
```
|
```
|
||||||
host ( ./cli.py )
|
host ( ./cli.py )
|
||||||
@@ -59,105 +87,195 @@ The Docker topology looks like this:
|
|||||||
▼
|
▼
|
||||||
┌─────────────────────────── bottle ──────────────────────────────────┐
|
┌─────────────────────────── bottle ──────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
│ ┌──────────────────┐ │
|
||||||
│ │ agent image │ HTTP(S) proxy │ egress image │ │
|
│ │ agent image │ HTTPS_PROXY │
|
||||||
│ │ (claude-code, │ ─────────────────►│ (mitmproxy; TLS bump │ │ HTTPS to
|
│ │ (claude-code, │ ────────────────────────┐ │
|
||||||
│ │ codex, etc) │ │ DLP scan, path │───┼──► allowlisted
|
│ │ built locally) │ │ │
|
||||||
│ │ │ │ matching, auth │ │ hosts
|
│ │ │ plain HTTP │ │
|
||||||
│ │ environ: proxy │ │ injection) │ │
|
│ │ skills, env, │ (token injection) ┌────▼─────────┐ │
|
||||||
│ │ URLs only, no │ └──────────────────────┘ │
|
│ │ ~/.gitconfig, │ ──────────────────►│ cred-proxy │ │
|
||||||
│ │ real tokens │ │
|
│ │ ~/.npmrc, tea │ │ (strips/inj │ │
|
||||||
│ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
|
│ │ │ │ Authoriz.) │ │
|
||||||
|
│ │ environ: URLs │ └─────┬────────┘ │
|
||||||
|
│ │ only, no real │ HTTPS_PROXY │ │
|
||||||
|
│ │ tokens │ ▼ │
|
||||||
|
│ │ │ ┌────────────────┐ │ HTTPS to
|
||||||
|
│ │ │ │ pipelock image │──────────┼──► allowlisted
|
||||||
|
│ │ │ │ (TLS bump, DLP │ │ hosts (incl.
|
||||||
|
│ │ │ │ body scan, │ │ cred-proxy
|
||||||
|
│ │ │ │ allowlist) │ │ upstreams)
|
||||||
|
│ │ │ └────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ git:// ┌────────────────┐ │ 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
|
||||||
│ └────────────────┘ │ via egress)
|
│ └────────────────┘ │ via pipelock)
|
||||||
│ │
|
│ │
|
||||||
│ agent on internal network (no default route); egress and │
|
│ agent on internal network (no default route); pipelock, │
|
||||||
│ git-gate straddle internal + egress networks. │
|
│ cred-proxy, and git-gate straddle internal + egress networks. │
|
||||||
│ egress is the single HTTP/HTTPS chokepoint — all agent HTTP/HTTPS │
|
│ pipelock is the single HTTP/HTTPS chokepoint — cred-proxy's │
|
||||||
│ traffic flows through it. git-gate's SSH egress is direct │
|
│ outbound traverses it too. git-gate's SSH egress is direct │
|
||||||
│ because egress is HTTP-only. │
|
│ because pipelock is HTTP-only. │
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
||||||
|
base) on first run; runs `claude` 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. If the upstream's hostname
|
||||||
|
isn't resolvable from the gate container (e.g. a Tailscale-only
|
||||||
|
host whose public DNS points elsewhere), pin its IP via
|
||||||
|
`ExtraHosts: { "<hostname>": "<ip>" }` on the `bottle.git` entry —
|
||||||
|
the gate's `/etc/hosts` gets the override while the agent's
|
||||||
|
`insteadOf` rewrite still keys off the original hostname. 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`.
|
||||||
|
|
||||||
## Install
|
When the agent exits, `cli.py` tears down every sidecar that was
|
||||||
|
brought up and the two networks; nothing about a bottle persists
|
||||||
Install the CLI with the bootstrap script:
|
between runs.
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bot-bottle doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
Python-native installers can use the package metadata directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
|
||||||
uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
Requires Docker on the host and a long-lived Claude Code OAuth token in
|
||||||
|
your shell env.
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
|
|
||||||
|
|
||||||
```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>`.
|
||||||
|
|
||||||
## Manifest
|
## Manifest
|
||||||
|
|
||||||
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`.
|
Bottles and agents live as Markdown files with YAML frontmatter under
|
||||||
|
`~/.claude-bottle/`. Each bottle is one file in `bottles/`, each agent
|
||||||
|
is one file in `agents/`:
|
||||||
|
|
||||||
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
|
```
|
||||||
|
~/.claude-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>/.claude-bottle/agents/<name>.md`. Those agents reference
|
||||||
|
bottles defined in `~/.claude-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.
|
||||||
|
|
||||||
|
### Example bottle (`~/.claude-bottle/bottles/gitea-dev.md`)
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
---
|
---
|
||||||
extends: claude # inherit the Claude provider boundary
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GIT_AUTHOR_NAME: didericis
|
GIT_AUTHOR_NAME: didericis
|
||||||
|
|
||||||
git:
|
git:
|
||||||
user:
|
- Name: claude-bottle
|
||||||
name: "Eric Bauerfeld"
|
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
|
||||||
email: "eric+claude@dideric.is"
|
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||||
remotes:
|
KnownHostKey: ssh-ed25519 AAAA...
|
||||||
gitea.dideric.is:
|
|
||||||
Name: bot-bottle
|
|
||||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
|
||||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
|
||||||
KnownHostKey: ssh-ed25519 AAAA...
|
|
||||||
|
|
||||||
egress:
|
# Routes declared here are held by a per-bottle cred-proxy sidecar,
|
||||||
|
# not the agent. Each route names a path the agent dials, the
|
||||||
|
# upstream the proxy forwards to, an auth_scheme, and a token_ref
|
||||||
|
# (host env var). The value goes into the sidecar's environ via
|
||||||
|
# `docker create -e`, never touches argv or disk. Optional `role`
|
||||||
|
# tags drive agent-side rewrites: anthropic-base-url (sets
|
||||||
|
# ANTHROPIC_BASE_URL), npm-registry (writes ~/.npmrc), git-insteadof
|
||||||
|
# (writes ~/.gitconfig), tea-login (writes ~/.config/tea/config.yml).
|
||||||
|
# See docs/prds/0010-cred-proxy.md.
|
||||||
|
cred_proxy:
|
||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- path: /anthropic/
|
||||||
auth:
|
upstream: https://api.anthropic.com
|
||||||
scheme: token # Bearer | token
|
auth_scheme: Bearer
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
||||||
matches: # optional — restrict to specific paths/methods/headers
|
role: anthropic-base-url
|
||||||
- paths:
|
- path: /gh-api/
|
||||||
- {type: prefix, value: /api/v1/}
|
upstream: https://api.github.com
|
||||||
methods: [GET, POST, PATCH, DELETE]
|
auth_scheme: Bearer
|
||||||
dlp: # optional — per-route detector overrides (default: all on)
|
token_ref: GH_PAT
|
||||||
outbound_detectors: [token_patterns, known_secrets]
|
- path: /gh-git/
|
||||||
inbound_detectors: false # disable response scanning for this host
|
upstream: https://github.com
|
||||||
|
auth_scheme: Bearer
|
||||||
|
token_ref: GH_PAT
|
||||||
|
role: git-insteadof
|
||||||
|
- path: /npm/
|
||||||
|
upstream: https://registry.npmjs.org
|
||||||
|
auth_scheme: Bearer
|
||||||
|
token_ref: NPM_TOKEN
|
||||||
|
role: npm-registry
|
||||||
|
|
||||||
|
# Egress is forced through a per-agent pipelock sidecar on a Docker
|
||||||
|
# `--internal` network — without the proxy the agent has no route
|
||||||
|
# off-box. The effective allowlist is the union of baked-in defaults
|
||||||
|
# (api.anthropic.com, claude.ai, ...) and the hostnames listed here.
|
||||||
|
# Pipelock also runs DLP scanning and detects URL-embedded
|
||||||
|
# high-entropy secrets. The resolved allowlist is shown in the y/N
|
||||||
|
# preflight before launch.
|
||||||
|
egress:
|
||||||
|
allowlist:
|
||||||
|
- github.com
|
||||||
|
- registry.npmjs.org
|
||||||
|
- pypi.org
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Backs my work on personal projects: Anthropic
|
||||||
gitea over SSH for push, token over HTTPS for the API.
|
OAuth via cred-proxy, gitea.dideric.is over SSH (with PAT for tea
|
||||||
|
API), and npm for publishing scoped packages.
|
||||||
````
|
````
|
||||||
|
|
||||||
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
### Example agent (`~/.claude-bottle/agents/gitea-helper.md`)
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
---
|
---
|
||||||
@@ -169,29 +287,99 @@ skills:
|
|||||||
You help maintain Gitea-hosted projects.
|
You help maintain Gitea-hosted projects.
|
||||||
````
|
````
|
||||||
|
|
||||||
**Egress route fields:**
|
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 — claude-bottle ignores them at launch but doesn't
|
||||||
|
reject them, so the same file can drop into `~/.claude/agents/` as a
|
||||||
|
Claude Code subagent.
|
||||||
|
|
||||||
| Field | Required | Description |
|
Unknown top-level frontmatter keys die at load with a "did you mean"
|
||||||
|---|---|---|
|
pointer; typos don't silently ghost into an empty config.
|
||||||
| `host` | yes | Hostname to allowlist. One entry per host. |
|
|
||||||
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
|
|
||||||
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
|
||||||
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
|
||||||
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
|
||||||
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
|
||||||
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
|
||||||
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
|
||||||
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
|
||||||
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
|
||||||
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
|
||||||
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
|
||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
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
|
||||||
|
`claude_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: OAuth token, not API key
|
||||||
|
|
||||||
|
claude-bottle authenticates `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
|
||||||
|
claude-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 `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The bottle reaches the Anthropic API only through the cred-proxy
|
||||||
|
sidecar. To let `claude` authenticate, declare a route in
|
||||||
|
`bottle.cred_proxy.routes` with `role: "anthropic-base-url"` and
|
||||||
|
`token_ref: "CLAUDE_BOTTLE_OAUTH_TOKEN"`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"path": "/anthropic/",
|
||||||
|
"upstream": "https://api.anthropic.com",
|
||||||
|
"auth_scheme": "Bearer",
|
||||||
|
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
||||||
|
"role": "anthropic-base-url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At launch, `cli.py` reads `CLAUDE_BOTTLE_OAUTH_TOKEN` from the host
|
||||||
|
env and forwards it into the cred-proxy container's environ — never
|
||||||
|
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 bottle without an `anthropic-base-url` route has no path to the
|
||||||
|
Anthropic API — there is no fallback that forwards the token directly
|
||||||
|
to the agent. Caveats: the token is bound to your subscription tier
|
||||||
|
(Pro/Max/Team/Enterprise), it does not work with `claude --bare`
|
||||||
|
(which only reads `ANTHROPIC_API_KEY`), and if it leaks, regenerate
|
||||||
|
via `claude setup-token` again. Reference:
|
||||||
|
<https://code.claude.com/docs/en/authentication>.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
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.
|
claude-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.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright 2026 Eric Bauerfeld. Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.
|
Copyright 2026 Eric Bauerfeld
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE)
|
||||||
|
for the full text.
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# Per-bottle sidecar bundle image (PRD 0024).
|
|
||||||
#
|
|
||||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
|
||||||
# supervise) into one. A small stdlib-Python init supervisor at
|
|
||||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
|
||||||
# propagates per-daemon stdout/stderr to the container log with a
|
|
||||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
|
||||||
#
|
|
||||||
# Layout:
|
|
||||||
#
|
|
||||||
# /usr/bin/gitleaks gitleaks binary
|
|
||||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
|
||||||
# /app/supervise_server.py + .py supervise MCP server
|
|
||||||
# /app/sidecar_init.py PID 1 supervisor
|
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
|
||||||
# /git/* bare repos, populated at runtime
|
|
||||||
# /run/supervise/queue/ bind-mounted at run time
|
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
|
||||||
#
|
|
||||||
# Exposed ports inside the container:
|
|
||||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
|
||||||
# 9418 git-gate (git-daemon)
|
|
||||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
|
||||||
# 9100 supervise (MCP HTTP)
|
|
||||||
|
|
||||||
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
|
||||||
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
|
||||||
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
|
||||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
|
||||||
|
|
||||||
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
|
||||||
# Python + mitmdump pre-installed — heavier than the others, so
|
|
||||||
# this stage starts there and pulls the standalone binaries in.
|
|
||||||
FROM mitmproxy/mitmproxy:11.1.3
|
|
||||||
|
|
||||||
# Run as root inside the bundle. The bundle is the isolation
|
|
||||||
# boundary; per-daemon user separation inside it is not load-bearing
|
|
||||||
# and complicates the supervisor's spawn path.
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Runtime system deps:
|
|
||||||
# git supplies the `git daemon` subcommand (no separate package)
|
|
||||||
# plus the core `git` binary the pre-receive hook invokes.
|
|
||||||
# openssh-client supplies the upstream SSH transport the
|
|
||||||
# pre-receive hook uses to forward accepted refs.
|
|
||||||
# ca-certificates is needed for mitmdump upstream TLS (the
|
|
||||||
# base image already has it; listed for explicitness).
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
git openssh-client ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Pull the standalone binaries into the final image.
|
|
||||||
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|
||||||
|
|
||||||
# Project Python: addon + server modules + the init supervisor.
|
|
||||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
|
||||||
# top-level siblings (absolute imports), matching the prior
|
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
|
||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
|
||||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
|
||||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
|
||||||
|
|
||||||
# Pre-create runtime directories the compose renderer + start
|
|
||||||
# step expect to exist. `docker cp` does not create intermediate
|
|
||||||
# dirs, and bind mounts won't either if the parent is missing.
|
|
||||||
RUN mkdir -p \
|
|
||||||
/etc/egress \
|
|
||||||
/etc/git-gate \
|
|
||||||
/git-gate/creds \
|
|
||||||
/git \
|
|
||||||
/run/supervise/queue \
|
|
||||||
/home/mitmproxy/.mitmproxy
|
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
|
||||||
# subset the bottle uses.
|
|
||||||
EXPOSE 8888 9099 9418 9420 9100
|
|
||||||
|
|
||||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
|
||||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
|
||||||
# propagation; no `exec` chain in the entrypoint itself.
|
|
||||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""bot-bottle: Python implementation of the agent container launcher."""
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
"""Agent provider runtime mapping.
|
|
||||||
|
|
||||||
The manifest owns the user-facing AgentProvider shape. This module is
|
|
||||||
the launch-time table that turns a provider template into an executable
|
|
||||||
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
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import inspect
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import tempfile
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
|
||||||
|
|
||||||
from .egress import EgressRoute
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .backend import Bottle, BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_CLAUDE = "claude"
|
|
||||||
PROVIDER_CODEX = "codex"
|
|
||||||
PROVIDER_PI = "pi"
|
|
||||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
|
||||||
|
|
||||||
# Hosts that egress injects the host ChatGPT bearer on when Codex
|
|
||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
|
||||||
PromptMode = Literal[
|
|
||||||
"append_file",
|
|
||||||
"read_prompt_file",
|
|
||||||
"print_read_prompt_file",
|
|
||||||
"append_system_prompt",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentProviderRuntime:
|
|
||||||
template: str
|
|
||||||
command: str
|
|
||||||
image: str
|
|
||||||
prompt_mode: PromptMode
|
|
||||||
bypass_args: tuple[str, ...]
|
|
||||||
resume_args: tuple[str, ...]
|
|
||||||
remote_control_args: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentProvisionDir:
|
|
||||||
guest_path: str
|
|
||||||
mode: str = "700"
|
|
||||||
owner: str = "node:node"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentProvisionFile:
|
|
||||||
host_path: Path
|
|
||||||
guest_path: str
|
|
||||||
mode: str = "600"
|
|
||||||
owner: str = "node:node"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentProvisionCommand:
|
|
||||||
argv: tuple[str, ...]
|
|
||||||
error: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentProvisionPlan:
|
|
||||||
"""Provider-owned guest setup.
|
|
||||||
|
|
||||||
Backends interpret this plan with their own copy/exec primitives.
|
|
||||||
Provider-specific content stays here so future provider plugins can
|
|
||||||
return the same shape without adding backend-plan fields.
|
|
||||||
|
|
||||||
`egress_routes` are provider-declared EgressRoutes that backends
|
|
||||||
pass to `Egress.prepare`. This keeps provider logic out of the
|
|
||||||
egress module — it merges provider routes generically without
|
|
||||||
knowing the provider type.
|
|
||||||
|
|
||||||
`hidden_env_names` is the set of env var names the provider injected
|
|
||||||
as non-secret placeholders. `print_util.visible_agent_env_names` uses
|
|
||||||
this to suppress them from the preflight summary so operators don't
|
|
||||||
mistake them for real credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
template: str
|
|
||||||
command: str
|
|
||||||
prompt_mode: PromptMode
|
|
||||||
image: str
|
|
||||||
dockerfile: str
|
|
||||||
guest_home: str
|
|
||||||
instance_name: str
|
|
||||||
prompt_file: Path
|
|
||||||
guest_env: dict[str, str]
|
|
||||||
has_prompt: bool = False
|
|
||||||
startup_args: tuple[str, ...] = ()
|
|
||||||
env_vars: dict[str, str] = field(default_factory=dict)
|
|
||||||
dirs: tuple[AgentProvisionDir, ...] = ()
|
|
||||||
files: tuple[AgentProvisionFile, ...] = ()
|
|
||||||
pre_copy: tuple[AgentProvisionCommand, ...] = ()
|
|
||||||
verify: tuple[AgentProvisionCommand, ...] = ()
|
|
||||||
egress_routes: tuple[EgressRoute, ...] = ()
|
|
||||||
hidden_env_names: frozenset[str] = field(default_factory=frozenset)
|
|
||||||
provisioned_env: dict[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def guest_home(self) -> str:
|
|
||||||
"""In-guest home directory for the agent user. Defaults to
|
|
||||||
`/home/node` to match the Debian-based bot-bottle-* images
|
|
||||||
(USER node). Override for plugins whose image runs as a
|
|
||||||
different user."""
|
|
||||||
return "/home/node"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dockerfile(self) -> Path:
|
|
||||||
"""Path to the provider's Dockerfile.
|
|
||||||
|
|
||||||
Default: the `Dockerfile` file next to this provider's
|
|
||||||
`agent_provider.py` module. Override to point at a non-standard
|
|
||||||
path."""
|
|
||||||
return Path(inspect.getfile(type(self))).parent / "Dockerfile"
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def provision_plan(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
dockerfile: str,
|
|
||||||
state_dir: Path,
|
|
||||||
instance_name: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
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 = "",
|
|
||||||
label: str = "",
|
|
||||||
color: str = "",
|
|
||||||
provider_settings: dict[str, object] | None = None,
|
|
||||||
) -> 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`."""
|
|
||||||
|
|
||||||
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
|
||||||
"""Install the egress MITM CA into the agent's trust store.
|
|
||||||
|
|
||||||
Default: Debian-style — cp the cert to the standard source path,
|
|
||||||
run update-ca-certificates, log the fingerprint. Override for
|
|
||||||
non-Debian base images or non-standard trust mechanisms."""
|
|
||||||
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
|
||||||
from .log import die
|
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
|
||||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
|
||||||
r = bottle.exec(
|
|
||||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"update-ca-certificates failed (exit {r.returncode}): "
|
|
||||||
f"stdout={(r.stdout or '').strip()!r} "
|
|
||||||
f"stderr={(r.stderr or '').strip()!r}"
|
|
||||||
)
|
|
||||||
log_ca_fingerprint(cert_host_path, label)
|
|
||||||
|
|
||||||
def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
|
||||||
"""Configure git inside the agent container.
|
|
||||||
|
|
||||||
Default: Debian/node — writes the git-gate insteadOf gitconfig
|
|
||||||
and sets user.name/email as node. Workspace copy runs through
|
|
||||||
BottleBackend.provision_workspace against the running bottle."""
|
|
||||||
from .log import info
|
|
||||||
|
|
||||||
manifest_bottle = plan.manifest.bottle
|
|
||||||
if manifest_bottle.git:
|
|
||||||
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
|
||||||
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
|
||||||
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
|
||||||
content = git_gate_render_gitconfig(
|
|
||||||
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
|
||||||
)
|
|
||||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
"w", dir=str(plan.stage_dir), prefix="gitconfig.", delete=False,
|
|
||||||
) as f:
|
|
||||||
f.write(content)
|
|
||||||
config_file = Path(f.name)
|
|
||||||
os.chmod(config_file, 0o600)
|
|
||||||
info(
|
|
||||||
f"writing {guest_gitconfig} with "
|
|
||||||
f"{len(manifest_bottle.git)} insteadOf rule(s)"
|
|
||||||
)
|
|
||||||
bottle.cp_in(str(config_file), guest_gitconfig)
|
|
||||||
bottle.exec(
|
|
||||||
f"chown node:node {shlex.quote(guest_gitconfig)} && "
|
|
||||||
f"chmod 644 {shlex.quote(guest_gitconfig)}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
|
|
||||||
gu = manifest_bottle.git_user
|
|
||||||
if not gu.is_empty():
|
|
||||||
if gu.name:
|
|
||||||
info(f"git config --global user.name = {gu.name!r}")
|
|
||||||
bottle.exec(
|
|
||||||
f"git config --global user.name {shlex.quote(gu.name)}",
|
|
||||||
user="node",
|
|
||||||
)
|
|
||||||
if gu.email:
|
|
||||||
info(f"git config --global user.email = {gu.email!r}")
|
|
||||||
bottle.exec(
|
|
||||||
f"git config --global user.email {shlex.quote(gu.email)}",
|
|
||||||
user="node",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_user_plugin(template: str) -> AgentProvider | None:
|
|
||||||
"""Check ~/.bot-bottle/contrib/<template>/agent_provider.py for a
|
|
||||||
user-defined AgentProvider subclass. Returns an instance if found,
|
|
||||||
None if the plugin directory doesn't exist, raises ValueError if
|
|
||||||
the file exists but exports no AgentProvider subclass."""
|
|
||||||
plugin_path = (
|
|
||||||
Path.home() / ".bot-bottle" / "contrib" / template / "agent_provider.py"
|
|
||||||
)
|
|
||||||
if not plugin_path.exists():
|
|
||||||
return None
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
f"_user_contrib_{template}.agent_provider", plugin_path
|
|
||||||
)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise ValueError(f"user plugin at {plugin_path} could not be loaded")
|
|
||||||
mod = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
||||||
for obj in vars(mod).values():
|
|
||||||
if (
|
|
||||||
isinstance(obj, type)
|
|
||||||
and issubclass(obj, AgentProvider)
|
|
||||||
and obj is not AgentProvider
|
|
||||||
):
|
|
||||||
return obj()
|
|
||||||
raise ValueError(
|
|
||||||
f"user plugin at {plugin_path} defines no AgentProvider subclass"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_provider(template: str) -> AgentProvider:
|
|
||||||
"""Resolve a provider template name to its plugin instance.
|
|
||||||
|
|
||||||
Checks ~/.bot-bottle/contrib/<template>/agent_provider.py first so
|
|
||||||
users can shadow a built-in for local testing. Falls through to the
|
|
||||||
built-in registry; raises ValueError for unknown names with no
|
|
||||||
matching user plugin."""
|
|
||||||
user_plugin = _load_user_plugin(template)
|
|
||||||
if user_plugin is not None:
|
|
||||||
return user_plugin
|
|
||||||
if template == PROVIDER_CLAUDE:
|
|
||||||
from .contrib.claude.agent_provider import ClaudeAgentProvider
|
|
||||||
return ClaudeAgentProvider()
|
|
||||||
if template == PROVIDER_CODEX:
|
|
||||||
from .contrib.codex.agent_provider import CodexAgentProvider
|
|
||||||
return CodexAgentProvider()
|
|
||||||
if template == PROVIDER_PI:
|
|
||||||
from .contrib.pi.agent_provider import PiAgentProvider
|
|
||||||
return PiAgentProvider()
|
|
||||||
raise ValueError(f"unknown agent provider template: {template!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_for(template: str) -> AgentProviderRuntime:
|
|
||||||
return get_provider(template).runtime
|
|
||||||
|
|
||||||
|
|
||||||
def build_agent_provision_plan(
|
|
||||||
*,
|
|
||||||
template: str,
|
|
||||||
dockerfile: str,
|
|
||||||
state_dir: Path,
|
|
||||||
instance_name: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
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 = "",
|
|
||||||
label: str = "",
|
|
||||||
color: str = "",
|
|
||||||
provider_settings: dict[str, object] | None = None,
|
|
||||||
) -> AgentProvisionPlan:
|
|
||||||
"""Back-compat shim — `prepare` callers stay the same; the work
|
|
||||||
now lives on the provider plugin."""
|
|
||||||
return get_provider(template).provision_plan(
|
|
||||||
dockerfile=dockerfile,
|
|
||||||
state_dir=state_dir,
|
|
||||||
instance_name=instance_name,
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
guest_env=guest_env,
|
|
||||||
auth_token=auth_token,
|
|
||||||
forward_host_credentials=forward_host_credentials,
|
|
||||||
host_env=host_env,
|
|
||||||
trusted_project_path=trusted_project_path,
|
|
||||||
label=label,
|
|
||||||
color=color,
|
|
||||||
provider_settings=provider_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_args(
|
|
||||||
prompt_mode: PromptMode,
|
|
||||||
prompt_path: str | None,
|
|
||||||
*,
|
|
||||||
argv: list[str] | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
if not prompt_path:
|
|
||||||
return []
|
|
||||||
if prompt_mode == "append_file":
|
|
||||||
return ["--append-system-prompt-file", prompt_path]
|
|
||||||
if prompt_mode == "read_prompt_file":
|
|
||||||
if argv and "resume" in argv:
|
|
||||||
return []
|
|
||||||
return [f"Read and follow the instructions in {prompt_path}."]
|
|
||||||
if prompt_mode == "print_read_prompt_file":
|
|
||||||
return ["-p", f"Read and follow the instructions in {prompt_path}."]
|
|
||||||
if prompt_mode == "append_system_prompt":
|
|
||||||
return ["--append-system-prompt", prompt_path]
|
|
||||||
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
|
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
"""Per-backend bottle factories.
|
|
||||||
|
|
||||||
A bottle is a running, isolated environment with claude inside. Each
|
|
||||||
backend exposes five methods:
|
|
||||||
|
|
||||||
prepare(spec, stage_dir=...) -> BottlePlan
|
|
||||||
Resolves names, validates host-side prerequisites, and writes
|
|
||||||
scratch files. No remote/runtime resources are created yet.
|
|
||||||
Safe to call before the y/N preflight.
|
|
||||||
|
|
||||||
launch(plan) -> ContextManager[Bottle]
|
|
||||||
Brings up the container (or VM, or remote machine), provisions
|
|
||||||
it, yields a Bottle handle, and tears everything down on exit.
|
|
||||||
|
|
||||||
prepare_cleanup() -> BottleCleanupPlan
|
|
||||||
Enumerates orphaned resources left behind by previous bottles
|
|
||||||
(containers, networks, ...). Idempotent; no side effects.
|
|
||||||
|
|
||||||
cleanup(plan) -> None
|
|
||||||
Actually removes everything described by the cleanup plan.
|
|
||||||
|
|
||||||
enumerate_active() -> Sequence[ActiveAgent]
|
|
||||||
Return every currently-running bottle on this backend, with
|
|
||||||
enough metadata for callers (CLI `list active`, dashboard
|
|
||||||
agents pane) to render a row.
|
|
||||||
|
|
||||||
Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND
|
|
||||||
(env var). When neither is set, compatible macOS hosts default to
|
|
||||||
`macos-container`; other hosts default to `smolmachines`. Per PRD 0003
|
|
||||||
the manifest does not carry a backend field; the host picks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import sys
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from contextlib import AbstractContextManager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Generic, Sequence, TypeVar
|
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
|
||||||
from ..egress import EgressPlan
|
|
||||||
from ..git_gate import GitGatePlan
|
|
||||||
from ..log import die, info
|
|
||||||
from ..manifest import Manifest, ManifestIndex
|
|
||||||
from ..supervise import SupervisePlan
|
|
||||||
from ..util import expand_tilde
|
|
||||||
from ..env import resolve_env, ResolvedEnv
|
|
||||||
from ..workspace import WorkspacePlan, workspace_plan
|
|
||||||
from .print_util import print_multi, visible_agent_env_names
|
|
||||||
from .util import host_skill_dir
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleSpec:
|
|
||||||
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
|
||||||
step consumes it and produces its own backend-specific plan.
|
|
||||||
Resolved values (image names, container name, scratch paths, runsc
|
|
||||||
availability) live on the plan, not the spec."""
|
|
||||||
|
|
||||||
manifest: ManifestIndex
|
|
||||||
agent_name: str
|
|
||||||
copy_cwd: bool
|
|
||||||
user_cwd: str
|
|
||||||
# PRD 0016 follow-up: when set, the backend's prepare step uses
|
|
||||||
# this identity instead of minting a fresh one — the resume path
|
|
||||||
# (`cli.py resume <identity>`) sets this to continue an existing
|
|
||||||
# bottle's state. Empty string for a fresh `start`.
|
|
||||||
identity: str = ""
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottlePlan(ABC):
|
|
||||||
"""Base output of a backend's prepare step. Concrete subclasses
|
|
||||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
|
||||||
|
|
||||||
spec: BottleSpec
|
|
||||||
manifest: Manifest
|
|
||||||
stage_dir: Path
|
|
||||||
git_gate_plan: GitGatePlan
|
|
||||||
|
|
||||||
@property
|
|
||||||
def guest_home(self) -> str:
|
|
||||||
return self.agent_provision.guest_home
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_host(self) -> str:
|
|
||||||
"""Host (and optional port) used in git-gate insteadOf URLs.
|
|
||||||
Docker uses the compose-network DNS alias; smolmachines
|
|
||||||
overrides with a loopback IP:port since TSI has no DNS."""
|
|
||||||
return "git-gate"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_scheme(self) -> str:
|
|
||||||
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
|
||||||
Docker (git daemon); 'http' for smolmachines (HTTP proxy
|
|
||||||
over a published host port)."""
|
|
||||||
return "git"
|
|
||||||
egress_plan: EgressPlan
|
|
||||||
supervise_plan: SupervisePlan | None
|
|
||||||
agent_provision: AgentProvisionPlan
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace_plan(self) -> WorkspacePlan:
|
|
||||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
|
||||||
"""Render the y/N preflight summary to stderr."""
|
|
||||||
del remote_control
|
|
||||||
spec = self.spec
|
|
||||||
manifest = self.manifest
|
|
||||||
agent = manifest.agent
|
|
||||||
bottle = manifest.bottle
|
|
||||||
|
|
||||||
env_names = visible_agent_env_names(
|
|
||||||
sorted(
|
|
||||||
set(bottle.env.keys())
|
|
||||||
| set(self.agent_provision.guest_env.keys())
|
|
||||||
),
|
|
||||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(file=sys.stderr)
|
|
||||||
info(f"agent : {spec.agent_name}")
|
|
||||||
info(f"provider : {self.agent_provision.template}")
|
|
||||||
print_multi("env ", env_names)
|
|
||||||
print_multi("skills ", list(agent.skills))
|
|
||||||
info(f"bottle : {agent.bottle}")
|
|
||||||
|
|
||||||
identity = manifest.git_identity_summary()
|
|
||||||
if identity:
|
|
||||||
info(f" git identity : {identity}")
|
|
||||||
|
|
||||||
git_lines = [
|
|
||||||
f"{u.name} → {u.upstream_host}:{u.upstream_port}"
|
|
||||||
for u in self.git_gate_plan.upstreams
|
|
||||||
]
|
|
||||||
if git_lines:
|
|
||||||
print_multi(" git gate ", git_lines)
|
|
||||||
|
|
||||||
if self.egress_plan.routes:
|
|
||||||
egress_lines = []
|
|
||||||
for r in self.egress_plan.routes:
|
|
||||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
|
||||||
egress_lines.append(f"{r.host}{auth}")
|
|
||||||
print_multi(" egress ", egress_lines)
|
|
||||||
print(file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleCleanupPlan(ABC):
|
|
||||||
"""Base output of a backend's prepare_cleanup step. Concrete
|
|
||||||
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
|
|
||||||
lists of resources to be removed and implement `print` + `empty`."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def print(self) -> None:
|
|
||||||
"""Render the cleanup y/N summary to stderr."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def empty(self) -> bool:
|
|
||||||
"""True iff there is nothing to clean up; the CLI uses this to
|
|
||||||
short-circuit before showing the y/N."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExecResult:
|
|
||||||
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
|
|
||||||
impl populates it from a `subprocess.CompletedProcess`, but a
|
|
||||||
future fly/smolmachines backend could populate it from any source
|
|
||||||
that produces a returncode + captured streams."""
|
|
||||||
|
|
||||||
returncode: int
|
|
||||||
stdout: str
|
|
||||||
stderr: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ActiveAgent:
|
|
||||||
"""One currently-running agent, as the CLI `list active` and
|
|
||||||
dashboard agents pane render it. ("Agent" is the project's
|
|
||||||
consistent name for the thing running inside a bottle — the
|
|
||||||
bottle is the container, the agent is what runs in it.)
|
|
||||||
|
|
||||||
Fields are deliberately backend-neutral. `services` is the set
|
|
||||||
of sidecar daemons currently up for this bottle (`egress`,
|
|
||||||
`git-gate`, `supervise`); the dashboard uses it to
|
|
||||||
gate edit verbs. `backend_name` is the matching key in
|
|
||||||
`_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active-
|
|
||||||
list rendering to disambiguate and by the dashboard's
|
|
||||||
re-attach path."""
|
|
||||||
|
|
||||||
backend_name: str
|
|
||||||
slug: str
|
|
||||||
agent_name: str # from metadata.json; "?" if missing
|
|
||||||
started_at: str # ISO 8601 from metadata.json; "" if missing
|
|
||||||
services: tuple[str, ...] # alphabetical
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class Bottle(ABC):
|
|
||||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
|
||||||
|
|
||||||
`exec_agent` runs the selected agent CLI inside the bottle and
|
|
||||||
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
|
|
||||||
and returns the captured result. `cp_in` copies a host path into
|
|
||||||
the bottle. `close` is an idempotent alias for context-manager
|
|
||||||
teardown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def agent_argv(
|
|
||||||
self, argv: list[str], *, tty: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return the host-side argv that runs the selected agent
|
|
||||||
inside the bottle. Used by `exec_agent` for foreground
|
|
||||||
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
|
||||||
which needs the argv up front (it spawns claude in a tmux
|
|
||||||
pane rather than as a child of the current process).
|
|
||||||
|
|
||||||
Implementations transparently inject
|
|
||||||
`--append-system-prompt-file` when the bottle was launched
|
|
||||||
with a provisioned prompt path."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
"""Run `script` as a POSIX shell script inside the bottle as
|
|
||||||
`user` (default `node`, matching the agent image's USER
|
|
||||||
directive) and return the captured stdout/stderr/returncode.
|
|
||||||
The bottle's environment (including HTTPS_PROXY pointing at
|
|
||||||
the egress sidecar) is inherited by the child. Non-zero
|
|
||||||
exit does not raise — callers inspect `returncode`
|
|
||||||
themselves.
|
|
||||||
|
|
||||||
Pass `user="root"` for shell-outs that need privileged file
|
|
||||||
writes / package install — provisioning calls that need root
|
|
||||||
bypass `Bottle.exec` and use the backend-specific raw
|
|
||||||
machine-exec helper, but the tests have a legitimate use
|
|
||||||
case for arbitrary-user runs."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def close(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PlanT = TypeVar("PlanT", bound=BottlePlan)
|
|
||||||
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
|
|
||||||
|
|
||||||
|
|
||||||
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|
||||||
"""Abstract base for selectable bottle backends. Concrete subclasses
|
|
||||||
(e.g. DockerBottleBackend) own their own prepare/launch impls.
|
|
||||||
Parameterized over the backend's concrete plan + cleanup-plan types
|
|
||||||
so subclass methods get the narrow type without isinstance
|
|
||||||
boilerplate."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
|
||||||
"""Template method: run cross-backend host-side validation, then
|
|
||||||
delegate to the subclass's `_resolve_plan` for the
|
|
||||||
backend-specific resolution (names, scratch files, etc.). The
|
|
||||||
validation step is enforced here so a future backend cannot
|
|
||||||
accidentally skip it. No remote/runtime resources are created."""
|
|
||||||
from .resolve_common import (
|
|
||||||
merge_provision_env_vars,
|
|
||||||
mint_slug,
|
|
||||||
prepare_agent_state_dir,
|
|
||||||
prepare_egress,
|
|
||||||
prepare_git_gate,
|
|
||||||
prepare_supervise,
|
|
||||||
resolve_manifest_dockerfile,
|
|
||||||
write_launch_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = self._validate(spec)
|
|
||||||
|
|
||||||
self._preflight()
|
|
||||||
|
|
||||||
manifest_bottle = manifest.bottle
|
|
||||||
manifest_agent_provider = manifest_bottle.agent_provider
|
|
||||||
agent_provider = get_provider(manifest_agent_provider.template)
|
|
||||||
resolved_env = resolve_env(manifest)
|
|
||||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
|
||||||
|
|
||||||
slug = mint_slug(spec)
|
|
||||||
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
|
||||||
|
|
||||||
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
|
||||||
# back to the provider plugin's bundled Dockerfile (next to its
|
|
||||||
# agent_provider.py module).
|
|
||||||
if manifest_agent_provider.dockerfile:
|
|
||||||
agent_dockerfile_path = resolve_manifest_dockerfile(
|
|
||||||
manifest_agent_provider.dockerfile, spec,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
|
||||||
|
|
||||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
|
||||||
|
|
||||||
agent_provision_plan = build_agent_provision_plan(
|
|
||||||
template=manifest_agent_provider.template,
|
|
||||||
dockerfile=agent_dockerfile_path,
|
|
||||||
state_dir=agent_dir,
|
|
||||||
instance_name=f"bot-bottle-{slug}",
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
guest_env=self._build_guest_env(resolved_env),
|
|
||||||
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
|
||||||
auth_token=manifest_agent_provider.auth_token,
|
|
||||||
host_env=dict(os.environ),
|
|
||||||
trusted_project_path=workspace.workdir,
|
|
||||||
label=spec.label,
|
|
||||||
color=spec.color,
|
|
||||||
provider_settings=manifest_agent_provider.settings,
|
|
||||||
)
|
|
||||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
|
||||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
|
||||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
|
||||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
|
||||||
|
|
||||||
return self._resolve_plan(
|
|
||||||
spec,
|
|
||||||
manifest=manifest,
|
|
||||||
slug=slug,
|
|
||||||
resolved_env=resolved_env,
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
"""
|
|
||||||
tasks to do before resolving a plan
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> Manifest:
|
|
||||||
"""Cross-backend pre-launch checks. Parses the selected agent and
|
|
||||||
its bottle (raising ManifestError on invalid content), confirms
|
|
||||||
skills are present on the host, and every git IdentityFile resolves.
|
|
||||||
|
|
||||||
Returns the loaded Manifest for the selected agent. Subclasses with
|
|
||||||
additional preconditions should override and call
|
|
||||||
`super()._validate(spec)` first."""
|
|
||||||
manifest = spec.manifest.load_for_agent(spec.agent_name)
|
|
||||||
self._validate_skills(manifest.agent.skills)
|
|
||||||
self._validate_agent_provider_dockerfile(spec, manifest)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
|
||||||
"""Each named skill must be a directory under the host's
|
|
||||||
`~/.claude/skills/`. The check is purely host-side, so the
|
|
||||||
default impl covers every backend."""
|
|
||||||
for name in skills:
|
|
||||||
path = host_skill_dir(name)
|
|
||||||
if not os.path.isdir(path):
|
|
||||||
die(
|
|
||||||
f"skill '{name}' not found on host at {path}. "
|
|
||||||
f"Create it under ~/.claude/skills/, then re-run."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
|
||||||
bottle = manifest.bottle
|
|
||||||
dockerfile = bottle.agent_provider.dockerfile
|
|
||||||
if not dockerfile:
|
|
||||||
return
|
|
||||||
path = Path(expand_tilde(dockerfile))
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = Path(spec.user_cwd) / path
|
|
||||||
if not path.is_file():
|
|
||||||
die(
|
|
||||||
f"agent_provider.dockerfile for bottle "
|
|
||||||
f"'{manifest.agent.bottle}' not found: {path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _resolve_plan(self,
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path) -> PlanT:
|
|
||||||
"""Backend-specific plan resolution: image/container names,
|
|
||||||
env-file, prompt-file, proxy plan, runtime detection. Called by
|
|
||||||
`prepare` after `_validate` succeeds. Instance name, image,
|
|
||||||
prompt file, Dockerfile path, and guest home all live on
|
|
||||||
`agent_provision_plan` — the source of truth."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
|
||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
|
||||||
|
|
||||||
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
|
||||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
|
||||||
the running bottle. Called from `launch` after the container
|
|
||||||
/ machine is up. Returns the in-container prompt path if a
|
|
||||||
prompt was provisioned, else None — the Bottle handle uses it
|
|
||||||
to decide whether to add provider-specific prompt args to the
|
|
||||||
agent's argv.
|
|
||||||
|
|
||||||
Default orchestration: ca → prompt → provider apply → skills
|
|
||||||
→ workspace → git → supervise-mcp. CA install runs first so
|
|
||||||
the agent's trust store is rebuilt before anything inside the
|
|
||||||
agent makes a TLS call.
|
|
||||||
|
|
||||||
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,
|
|
||||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
|
||||||
on the agent's HTTP_PROXY path so every tool that respects
|
|
||||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
|
||||||
intercepted without per-tool reconfiguration."""
|
|
||||||
provider = get_provider(plan.agent_provision.template)
|
|
||||||
provider.provision_ca(bottle, plan)
|
|
||||||
prompt_path = provider.provision_prompt(plan, bottle)
|
|
||||||
provider.provision(plan, bottle)
|
|
||||||
provider.provision_skills(plan, bottle)
|
|
||||||
self.provision_workspace(plan, bottle)
|
|
||||||
provider.provision_git(bottle, plan)
|
|
||||||
provider.provision_supervise_mcp(
|
|
||||||
plan, bottle, self.supervise_mcp_url(plan),
|
|
||||||
)
|
|
||||||
return prompt_path
|
|
||||||
|
|
||||||
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
|
||||||
"""Copy the operator workspace into the running bottle.
|
|
||||||
|
|
||||||
This is the only supported workspace-provisioning path: Docker
|
|
||||||
does not build a derived image containing the current
|
|
||||||
workspace."""
|
|
||||||
workspace = plan.workspace_plan
|
|
||||||
if not (workspace.enabled and workspace.copy_contents):
|
|
||||||
return
|
|
||||||
|
|
||||||
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
|
|
||||||
guest_path = shlex.quote(workspace.guest_path)
|
|
||||||
guest_parent = shlex.quote(guest_parent)
|
|
||||||
owner = shlex.quote(workspace.owner)
|
|
||||||
mode = shlex.quote(workspace.mode)
|
|
||||||
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
|
||||||
bottle.exec(
|
|
||||||
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
|
||||||
bottle.exec(
|
|
||||||
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: PlanT) -> str:
|
|
||||||
"""Return the agent-side URL of the per-bottle supervise
|
|
||||||
sidecar, or "" when this bottle has no sidecar. The provider
|
|
||||||
plugin's `provision_supervise_mcp` uses it to register the
|
|
||||||
MCP entry inside the guest.
|
|
||||||
|
|
||||||
Default returns "" so backends without supervise support
|
|
||||||
don't have to implement it. Docker and smolmachines override."""
|
|
||||||
del plan
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
|
||||||
effects; safe to call before the y/N."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def cleanup(self, plan: CleanupT) -> None:
|
|
||||||
"""Remove everything described by the cleanup plan."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
"""Return every currently-running agent on this backend.
|
|
||||||
Empty when none. Backend-specific: docker queries `docker
|
|
||||||
compose ls`; smolmachines queries `smolvm machine ls --json`
|
|
||||||
+ cross-references its bundle container."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
"""Whether this backend's runtime prerequisites are satisfied
|
|
||||||
on the current host. Docker → `docker` on PATH; smolmachines
|
|
||||||
→ `smolvm` on PATH. Used by the cross-backend
|
|
||||||
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
|
||||||
the operator hasn't installed, so a docker-only host
|
|
||||||
doesn't fail when `cli.py list active` walks past
|
|
||||||
smolmachines."""
|
|
||||||
|
|
||||||
|
|
||||||
# Import concrete backend classes AFTER the base types are defined, so
|
|
||||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
|
||||||
# via `from . import ...` without hitting a partially-initialized module.
|
|
||||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
# Freezer is imported after the backend classes for the same reason:
|
|
||||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
|
||||||
# defined first.
|
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
|
|
||||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
|
||||||
# over its own plan type. Concrete plan types are erased here because
|
|
||||||
# the registry is selected at runtime and the CLI only needs the
|
|
||||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
|
||||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
|
||||||
"docker": DockerBottleBackend(),
|
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
|
||||||
"smolmachines": SmolmachinesBottleBackend(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_bottle_backend(
|
|
||||||
name: str | None = None,
|
|
||||||
) -> BottleBackend[Any, Any]:
|
|
||||||
"""Resolve the bottle backend.
|
|
||||||
|
|
||||||
`name` precedence:
|
|
||||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
|
||||||
2. BOT_BOTTLE_BACKEND env var
|
|
||||||
3. `macos-container` on compatible macOS hosts
|
|
||||||
4. default `smolmachines`
|
|
||||||
|
|
||||||
Dies with a pointer at the known backends if the chosen name
|
|
||||||
isn't implemented."""
|
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
|
||||||
if resolved not in _BACKENDS:
|
|
||||||
known = ", ".join(sorted(_BACKENDS))
|
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
|
||||||
return _BACKENDS[resolved]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_backend_name() -> str:
|
|
||||||
if has_backend("macos-container"):
|
|
||||||
return "macos-container"
|
|
||||||
return "smolmachines"
|
|
||||||
|
|
||||||
|
|
||||||
def known_backend_names() -> tuple[str, ...]:
|
|
||||||
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
|
||||||
picker."""
|
|
||||||
return tuple(sorted(_BACKENDS))
|
|
||||||
|
|
||||||
|
|
||||||
def has_backend(name: str) -> bool:
|
|
||||||
"""Whether the named backend's runtime prerequisites are
|
|
||||||
available on the current host. Cross-backend callers (list,
|
|
||||||
cleanup) skip unavailable backends so a docker-only host
|
|
||||||
doesn't fail when the smolmachines backend isn't installed,
|
|
||||||
and vice versa.
|
|
||||||
|
|
||||||
Returns False for unknown names so callers can pass
|
|
||||||
arbitrary input without separate validation."""
|
|
||||||
if name not in _BACKENDS:
|
|
||||||
return False
|
|
||||||
return _BACKENDS[name].is_available()
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active_agents() -> list[ActiveAgent]:
|
|
||||||
"""All currently-running agents, across every available
|
|
||||||
backend. Used by CLI `list active` and the dashboard's agents
|
|
||||||
pane so neither has to know which backends exist. Skips
|
|
||||||
backends whose `is_available()` reports False.
|
|
||||||
|
|
||||||
Sorted by `(started_at, slug)` so the list is stable across
|
|
||||||
dashboard refresh ticks — agents don't shift position while
|
|
||||||
the operator navigates with arrow keys. ISO 8601 timestamps
|
|
||||||
sort lexicographically in chronological order; `slug` is the
|
|
||||||
deterministic tiebreaker. Agents with missing metadata
|
|
||||||
(`started_at == ""`) sort first."""
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for name in known_backend_names():
|
|
||||||
if not has_backend(name):
|
|
||||||
continue
|
|
||||||
out.extend(_BACKENDS[name].enumerate_active())
|
|
||||||
out.sort(key=lambda a: (a.started_at, a.slug))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ActiveAgent",
|
|
||||||
"Bottle",
|
|
||||||
"BottleBackend",
|
|
||||||
"BottleCleanupPlan",
|
|
||||||
"BottlePlan",
|
|
||||||
"BottleSpec",
|
|
||||||
"CommitCancelled",
|
|
||||||
"ExecResult",
|
|
||||||
"Freezer",
|
|
||||||
"enumerate_active_agents",
|
|
||||||
"get_bottle_backend",
|
|
||||||
"get_freezer",
|
|
||||||
"has_backend",
|
|
||||||
"known_backend_names",
|
|
||||||
]
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""DockerBottleBackend — the Docker implementation of BottleBackend.
|
|
||||||
|
|
||||||
This module is a thin façade. The real work lives in four siblings:
|
|
||||||
|
|
||||||
- resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
|
|
||||||
- launch.py — bring-up + teardown context manager
|
|
||||||
- cleanup.py — orphan enumeration + removal
|
|
||||||
- enumerate.py — active-agent listing
|
|
||||||
|
|
||||||
The base class's `prepare` template runs cross-backend host-side
|
|
||||||
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
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Generator, Sequence
|
|
||||||
|
|
||||||
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
|
||||||
from . import cleanup as _cleanup
|
|
||||||
from . import enumerate as _enumerate
|
|
||||||
from . import launch as _launch
|
|
||||||
from . import resolve_plan as _resolve_plan
|
|
||||||
from .bottle import DockerBottle
|
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
|
||||||
from .bottle_plan import DockerBottlePlan
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
|
||||||
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
|
|
||||||
when set to `docker`; retained as a legacy/example backend."""
|
|
||||||
|
|
||||||
name = "docker"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
"""`docker` on PATH is sufficient; we don't probe `docker info`
|
|
||||||
eagerly because the cross-backend enumerator runs this on
|
|
||||||
every `list active` and we'd pay a subprocess per call. A
|
|
||||||
broken daemon will surface its own error during prepare /
|
|
||||||
launch."""
|
|
||||||
return shutil.which("docker") is not None
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
_resolve_plan.preflight()
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return _resolve_plan.build_guest_env(resolved_env)
|
|
||||||
|
|
||||||
def _resolve_plan(
|
|
||||||
self,
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> DockerBottlePlan:
|
|
||||||
return _resolve_plan.resolve_plan(
|
|
||||||
spec,
|
|
||||||
manifest=manifest,
|
|
||||||
slug=slug,
|
|
||||||
resolved_env=resolved_env,
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
|
||||||
yield bottle
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
|
||||||
"""Docker bottles reach the supervise sidecar via the
|
|
||||||
compose-network alias `supervise:9100`. No per-bottle URL
|
|
||||||
plumbing needed; the alias resolves inside the bridge."""
|
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return ""
|
|
||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
|
||||||
return _cleanup.prepare_cleanup()
|
|
||||||
|
|
||||||
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
|
|
||||||
_cleanup.cleanup(plan)
|
|
||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
return _enumerate.enumerate_active()
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""DockerBottle — concrete Bottle handle yielded by DockerBottleBackend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
|
||||||
from .. import Bottle, ExecResult
|
|
||||||
from ..terminal import exec_shell_script
|
|
||||||
|
|
||||||
|
|
||||||
class DockerBottle(Bottle):
|
|
||||||
"""Concrete Bottle for Docker."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
container: str,
|
|
||||||
teardown: Callable[[], None],
|
|
||||||
prompt_path_in_container: str | None,
|
|
||||||
*,
|
|
||||||
agent_command: str = "claude",
|
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
terminal_title: str = "",
|
|
||||||
terminal_color: str = "",
|
|
||||||
agent_workdir: str = "/home/node",
|
|
||||||
):
|
|
||||||
self.name = container
|
|
||||||
self._teardown = teardown
|
|
||||||
self.prompt_path = prompt_path_in_container
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
|
||||||
self.agent_command = agent_command
|
|
||||||
self.terminal_title = terminal_title
|
|
||||||
self.terminal_color = terminal_color
|
|
||||||
self.agent_provider_template = agent_provider_template
|
|
||||||
self.agent_workdir = agent_workdir
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def agent_argv(
|
|
||||||
self, argv: list[str], *, tty: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
full_argv = list(argv)
|
|
||||||
full_argv.extend(
|
|
||||||
prompt_args(cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=full_argv)
|
|
||||||
)
|
|
||||||
cmd = ["docker", "exec"]
|
|
||||||
if tty:
|
|
||||||
cmd.append("-it")
|
|
||||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
|
||||||
cmd.extend(["-w", self.agent_workdir])
|
|
||||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
|
||||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
|
||||||
if script is None:
|
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
|
||||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
|
||||||
# about quoting; the script source lands inside the container
|
|
||||||
# without crossing argv. `-u <user>` overrides the image's
|
|
||||||
# default USER — defaults to `node` which is already the
|
|
||||||
# image's USER, so the explicit flag is a no-op there but
|
|
||||||
# keeps the cross-backend contract uniform.
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "exec", "-u", user, "-i", self.name, "sh", "-s"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return ExecResult(
|
|
||||||
returncode=result.returncode,
|
|
||||||
stdout=result.stdout,
|
|
||||||
stderr=result.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", host_path, f"{self.name}:{container_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._closed:
|
|
||||||
return
|
|
||||||
self._closed = True
|
|
||||||
self._teardown()
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
|
|
||||||
|
|
||||||
PRD 0018 chunk 4: cleanup is centered on compose projects. `docker
|
|
||||||
compose ls` is the source of truth for what's running; the plan
|
|
||||||
carries the projects to `compose down`, plus three fallback buckets
|
|
||||||
for legacy / orphan resources:
|
|
||||||
|
|
||||||
- stray_containers: pre-compose `bot-bottle-*` containers not
|
|
||||||
attached to any compose project. Cleared via `docker rm -f`.
|
|
||||||
- stray_networks: same idea for networks. Cleared via
|
|
||||||
`docker network rm`.
|
|
||||||
- orphan_state_dirs: per-bottle state dirs under
|
|
||||||
~/.bot-bottle/state/ that have no live compose project AND
|
|
||||||
no `.preserve` marker. Reaped via `shutil.rmtree`.
|
|
||||||
|
|
||||||
Compose-managed networks are removed by `compose down --volumes`,
|
|
||||||
so they don't appear in stray_networks for a normal project — only
|
|
||||||
truly leftover ones.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from ...log import info
|
|
||||||
from .. import BottleCleanupPlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DockerBottleCleanupPlan(BottleCleanupPlan):
|
|
||||||
"""Resources DockerBottleBackend.cleanup will remove. Produced by
|
|
||||||
`prepare_cleanup`; sorted so the y/N output is stable."""
|
|
||||||
|
|
||||||
projects: tuple[str, ...]
|
|
||||||
stray_containers: tuple[str, ...]
|
|
||||||
stray_networks: tuple[str, ...]
|
|
||||||
orphan_state_dirs: tuple[str, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
return (
|
|
||||||
not self.projects
|
|
||||||
and not self.stray_containers
|
|
||||||
and not self.stray_networks
|
|
||||||
and not self.orphan_state_dirs
|
|
||||||
)
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
print(file=sys.stderr)
|
|
||||||
for name in self.projects:
|
|
||||||
info(f"compose project: {name}")
|
|
||||||
for name in self.stray_containers:
|
|
||||||
info(f"stray container: {name}")
|
|
||||||
for name in self.stray_networks:
|
|
||||||
info(f"stray network: {name}")
|
|
||||||
for name in self.orphan_state_dirs:
|
|
||||||
info(f"orphan state: {name}")
|
|
||||||
print(file=sys.stderr)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""DockerBottlePlan — concrete subclass of BottlePlan.
|
|
||||||
|
|
||||||
Carries the Docker-specific resolved fields produced by
|
|
||||||
DockerBottleBackend.prepare. The launch step consumes it without
|
|
||||||
further resolution; preflight rendering is inherited from BottlePlan.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode
|
|
||||||
from .. import BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DockerBottlePlan(BottlePlan):
|
|
||||||
"""Docker-specific resolved fields produced by
|
|
||||||
DockerBottleBackend.prepare. Inherits `spec`, `stage_dir`,
|
|
||||||
`git_gate_plan`, `egress_plan`, `supervise_plan`, and
|
|
||||||
`agent_provision` from BottlePlan."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
# name -> value for vars forwarded into the docker-run child process
|
|
||||||
# via subprocess env (so values never land on argv or in a file).
|
|
||||||
# repr=False keeps secret/interpolated/OAuth values out of any
|
|
||||||
# accidental log of the plan dataclass.
|
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
|
||||||
use_runsc: bool
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_name(self) -> str:
|
|
||||||
return self.agent_provision.instance_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image(self) -> str:
|
|
||||||
return self.agent_provision.image
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dockerfile_path(self) -> str:
|
|
||||||
"""Absolute path to the Dockerfile that builds `image`. Sourced
|
|
||||||
from the agent provision plan — the manifest may override per
|
|
||||||
bottle; otherwise the provider plugin's bundled Dockerfile."""
|
|
||||||
return self.agent_provision.dockerfile
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_file(self) -> Path:
|
|
||||||
return self.agent_provision.prompt_file
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_command(self) -> str:
|
|
||||||
return self.agent_provision.command
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_prompt_mode(self) -> PromptMode:
|
|
||||||
return self.agent_provision.prompt_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_provider_template(self) -> str:
|
|
||||||
return self.agent_provision.template
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""capability_apply — host-side orchestrator for capability-block
|
|
||||||
remediation (PRD 0016).
|
|
||||||
|
|
||||||
On approval of a capability-block proposal, the dashboard calls
|
|
||||||
apply_capability_change(slug, new_dockerfile) which:
|
|
||||||
|
|
||||||
1. Snapshots the agent's transcript dir to
|
|
||||||
~/.bot-bottle/state/<slug>/transcript/ (best-effort).
|
|
||||||
2. Pushes the agent's working tree via `git push` (best-effort —
|
|
||||||
no upstream / no commits / no git repo all skip with a log).
|
|
||||||
3. Writes the new Dockerfile to
|
|
||||||
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
|
||||||
state). The next `cli.py start <agent>` picks it up.
|
|
||||||
4. Force-removes the agent container + all sidecars + the
|
|
||||||
per-bottle networks. Idempotent — missing resources are not
|
|
||||||
errors.
|
|
||||||
|
|
||||||
Returns (before, after) Dockerfile contents so the dashboard can
|
|
||||||
record / render the diff. (capability-block has no audit log per
|
|
||||||
PRD 0013 — the per-bottle Dockerfile state is its own record.)
|
|
||||||
|
|
||||||
This is "fire-and-forget" from the agent's perspective: by the time
|
|
||||||
the dashboard writes the response file the supervise sidecar is
|
|
||||||
gone, so the agent's tool call connection drops without ever
|
|
||||||
receiving the response. The replacement agent (next manual
|
|
||||||
`cli.py start`) sees the new Dockerfile and starts from there.
|
|
||||||
v1 does not auto-relaunch — see PRD 0016's capability-block return
|
|
||||||
semantics open question.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...agent_provider import get_provider
|
|
||||||
from ...log import info, warn
|
|
||||||
from ...bottle_state import (
|
|
||||||
mark_preserved,
|
|
||||||
per_bottle_dockerfile,
|
|
||||||
transcript_snapshot_dir,
|
|
||||||
write_per_bottle_dockerfile,
|
|
||||||
)
|
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
|
||||||
|
|
||||||
|
|
||||||
# Agent home inside the container (per the repo Dockerfile's
|
|
||||||
# `USER node` + `WORKDIR /home/node`). Used to locate the transcript
|
|
||||||
# dir + the workspace dir for git push.
|
|
||||||
_AGENT_HOME_IN_CONTAINER = "/home/node"
|
|
||||||
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
|
||||||
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
|
||||||
|
|
||||||
# Per-bottle resource name patterns (mirroring prepare.py).
|
|
||||||
def _agent_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def _per_bottle_container_names(slug: str) -> list[str]:
|
|
||||||
"""All container names that belong to this bottle. Missing
|
|
||||||
containers are silently skipped by the teardown helper, so it's
|
|
||||||
fine to include names that don't exist for a given bottle."""
|
|
||||||
return [
|
|
||||||
_agent_container_name(slug),
|
|
||||||
sidecar_bundle_container_name(slug),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _per_bottle_network_names(slug: str) -> list[str]:
|
|
||||||
return [
|
|
||||||
f"bot-bottle-net-{slug}",
|
|
||||||
f"bot-bottle-egress-{slug}",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityApplyError(RuntimeError):
|
|
||||||
"""Raised when the apply fails in a way that should keep the
|
|
||||||
proposal pending (so the operator can retry). Best-effort
|
|
||||||
failures (transcript snapshot, git push) do not raise — they
|
|
||||||
just log and proceed."""
|
|
||||||
|
|
||||||
|
|
||||||
# --- Public helpers --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_dockerfile(slug: str) -> str:
|
|
||||||
"""Return the Dockerfile content the next `cli.py start <agent>`
|
|
||||||
would use for this bottle. If a per-bottle override exists, that
|
|
||||||
one; otherwise the repo's Dockerfile.
|
|
||||||
|
|
||||||
Used by the operator-edit verb to show the current source of
|
|
||||||
truth, and by apply_capability_change for the before-diff."""
|
|
||||||
override = per_bottle_dockerfile(slug)
|
|
||||||
if override is not None:
|
|
||||||
return override
|
|
||||||
repo_dockerfile = get_provider("claude").dockerfile
|
|
||||||
if repo_dockerfile.is_file():
|
|
||||||
return repo_dockerfile.read_text()
|
|
||||||
raise CapabilityApplyError(
|
|
||||||
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
|
|
||||||
f"{repo_dockerfile}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
|
||||||
"""End-to-end capability-block remediation. See module docstring
|
|
||||||
for the sequence. Returns (before, after) Dockerfile content."""
|
|
||||||
if not new_dockerfile.strip():
|
|
||||||
raise CapabilityApplyError("proposed Dockerfile is empty")
|
|
||||||
before = fetch_current_dockerfile(slug)
|
|
||||||
|
|
||||||
snapshot_transcript(slug)
|
|
||||||
_push_working_tree(slug)
|
|
||||||
write_per_bottle_dockerfile(slug, new_dockerfile)
|
|
||||||
# Set the preserve marker BEFORE teardown so cli.py's session-end
|
|
||||||
# cleanup sees it and keeps the state dir intact for the
|
|
||||||
# operator's `cli.py resume <identity>`. Without the marker the
|
|
||||||
# state dir would be deleted as part of normal session end.
|
|
||||||
mark_preserved(slug)
|
|
||||||
_teardown_bottle(slug)
|
|
||||||
|
|
||||||
return before, new_dockerfile
|
|
||||||
|
|
||||||
|
|
||||||
# --- Internals -------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_transcript(slug: str) -> None:
|
|
||||||
"""`docker cp` /home/node/.claude out of the agent container into
|
|
||||||
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
|
|
||||||
container, missing dir, or cp error all log a warning and return.
|
|
||||||
The transcript is what `claude --resume` reads to pick up where
|
|
||||||
the agent left off.
|
|
||||||
|
|
||||||
Called from two places:
|
|
||||||
- capability-apply, before tearing the bottle down.
|
|
||||||
- cli.py's session-end path, before the launch context closes,
|
|
||||||
so a crash or normal exit also leaves a transcript on disk
|
|
||||||
(deleted along with the state dir on clean exit, kept on
|
|
||||||
crash or capability-block per the preserve marker)."""
|
|
||||||
container = _agent_container_name(slug)
|
|
||||||
dest = transcript_snapshot_dir(slug)
|
|
||||||
if dest.exists():
|
|
||||||
# Remove any prior snapshot so the new one is a clean copy.
|
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "cp", f"{container}:{_AGENT_TRANSCRIPT_IN_CONTAINER}", str(dest)],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"transcript snapshot skipped "
|
|
||||||
f"({(r.stderr or '').strip() or 'no transcript dir in container?'})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
info(f"transcript snapshotted to {dest}")
|
|
||||||
|
|
||||||
|
|
||||||
def _push_working_tree(slug: str) -> None:
|
|
||||||
"""`docker exec <agent> git push` from /home/node/workspace.
|
|
||||||
Best-effort: not-a-git-repo, no upstream, nothing-to-push, no
|
|
||||||
network all log a warning and return. The replacement bottle
|
|
||||||
will pick up whatever's actually upstream."""
|
|
||||||
container = _agent_container_name(slug)
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "exec", container, "sh", "-c",
|
|
||||||
f"cd {_AGENT_WORKSPACE_IN_CONTAINER} && "
|
|
||||||
f"git rev-parse --is-inside-work-tree >/dev/null 2>&1 && "
|
|
||||||
f"git push origin HEAD 2>&1 || true",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"capability-apply: git push skipped "
|
|
||||||
f"({(r.stderr or '').strip() or 'docker exec failed'})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
output = (r.stdout or "").strip()
|
|
||||||
if output:
|
|
||||||
info(f"capability-apply: git push: {output}")
|
|
||||||
else:
|
|
||||||
info("capability-apply: git push ran (no output — likely not a git workspace)")
|
|
||||||
|
|
||||||
|
|
||||||
def _teardown_bottle(slug: str) -> None:
|
|
||||||
"""Force-remove all per-bottle docker resources. Idempotent —
|
|
||||||
`docker rm -f` / `docker network rm` silently ignore missing
|
|
||||||
names, so this can be called even mid-rebuild."""
|
|
||||||
info(f"capability-apply: tearing down bottle {slug}")
|
|
||||||
for name in _per_bottle_container_names(slug):
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
for net in _per_bottle_network_names(slug):
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "network", "rm", net],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CapabilityApplyError",
|
|
||||||
"apply_capability_change",
|
|
||||||
"fetch_current_dockerfile",
|
|
||||||
"snapshot_transcript",
|
|
||||||
]
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""Cleanup for the Docker bottle backend.
|
|
||||||
|
|
||||||
PRD 0018 chunk 4: cleanup is centered on `docker compose ls`.
|
|
||||||
Pre-compose code paths could leave bare containers / networks
|
|
||||||
without a compose project; those still show up via the prefix
|
|
||||||
scan, just as a fallback bucket alongside the project list.
|
|
||||||
|
|
||||||
`prepare_cleanup` enumerates:
|
|
||||||
|
|
||||||
- Live compose projects whose name starts with `bot-bottle-`.
|
|
||||||
- `bot-bottle-*` containers that aren't part of any compose
|
|
||||||
project (legacy orphans).
|
|
||||||
- `bot-bottle-*` networks that aren't tied to a compose
|
|
||||||
project (legacy orphans; compose-managed networks come down
|
|
||||||
with `compose down --volumes` and don't appear here).
|
|
||||||
- State dirs under ~/.bot-bottle/state/<identity>/ with no
|
|
||||||
live compose project AND no `.preserve` marker.
|
|
||||||
|
|
||||||
`cleanup` removes everything in the plan.
|
|
||||||
|
|
||||||
Active-agent enumeration lives in `backend/docker/enumerate.py`
|
|
||||||
(mirror of `backend/smolmachines/enumerate.py`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ... import supervise as _supervise
|
|
||||||
from ...log import info, warn
|
|
||||||
from . import util as docker_mod
|
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
|
||||||
from ...bottle_state import bottle_state_dir, is_preserved
|
|
||||||
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
|
||||||
"""All bot-bottle-prefixed containers, running or stopped."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "-a",
|
|
||||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
|
||||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(f"docker ps failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for line in (result.stdout or "").splitlines():
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
name, _, project = line.partition("\t")
|
|
||||||
# Stray = no compose label. Compose-managed containers carry
|
|
||||||
# `com.docker.compose.project=<name>`; we'll reap those via
|
|
||||||
# `compose down`, not via container rm.
|
|
||||||
if not project:
|
|
||||||
out.append(name)
|
|
||||||
return sorted(set(out))
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_networks() -> list[str]:
|
|
||||||
"""All bot-bottle-prefixed networks not currently attached
|
|
||||||
to a compose project. Compose-managed networks have a
|
|
||||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
|
||||||
code paths) don't."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "ls",
|
|
||||||
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
|
||||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(f"docker network ls failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for line in (result.stdout or "").splitlines():
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
name, _, project = line.partition("\t")
|
|
||||||
if not project:
|
|
||||||
out.append(name)
|
|
||||||
return sorted(set(out))
|
|
||||||
|
|
||||||
|
|
||||||
def _list_orphan_state_dirs(
|
|
||||||
live_projects: set[str], protected_identities: set[str],
|
|
||||||
) -> list[str]:
|
|
||||||
"""State identities whose compose project isn't running and
|
|
||||||
that don't have a `.preserve` marker. `.preserve` means the
|
|
||||||
user (or an auto-preserve-on-crash) wants the state kept for
|
|
||||||
`resume`.
|
|
||||||
|
|
||||||
`protected_identities` is the set of slugs that are live in
|
|
||||||
ANY backend — used so this docker-side check doesn't reap a
|
|
||||||
running smolmachines bottle's state dir (the layout is shared
|
|
||||||
across both backends)."""
|
|
||||||
state_root = _supervise.bot_bottle_root() / "state"
|
|
||||||
if not state_root.is_dir():
|
|
||||||
return []
|
|
||||||
orphans: list[str] = []
|
|
||||||
for child in sorted(state_root.iterdir()):
|
|
||||||
if not child.is_dir():
|
|
||||||
continue
|
|
||||||
identity = child.name
|
|
||||||
project = f"{COMPOSE_PROJECT_PREFIX}{identity}"
|
|
||||||
if project in live_projects:
|
|
||||||
continue
|
|
||||||
if identity in protected_identities:
|
|
||||||
continue
|
|
||||||
if is_preserved(identity):
|
|
||||||
continue
|
|
||||||
orphans.append(identity)
|
|
||||||
return orphans
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> DockerBottleCleanupPlan:
|
|
||||||
"""Enumerate everything cleanup will touch. No removals.
|
|
||||||
|
|
||||||
Pulls the union of live identities across backends via
|
|
||||||
`enumerate_active_agents()` so the orphan-state-dir bucket
|
|
||||||
doesn't include slugs whose smolmachines VM is still up."""
|
|
||||||
docker_mod.require_docker()
|
|
||||||
projects = list_compose_projects()
|
|
||||||
project_set = set(projects)
|
|
||||||
# Late import to avoid a circular at module-load time —
|
|
||||||
# the backend package's __init__ imports this module.
|
|
||||||
from .. import enumerate_active_agents
|
|
||||||
protected = {a.slug for a in enumerate_active_agents()}
|
|
||||||
return DockerBottleCleanupPlan(
|
|
||||||
projects=tuple(projects),
|
|
||||||
stray_containers=tuple(_list_prefixed_containers()),
|
|
||||||
stray_networks=tuple(_list_prefixed_networks()),
|
|
||||||
orphan_state_dirs=tuple(
|
|
||||||
_list_orphan_state_dirs(project_set, protected),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
|
||||||
"""Remove everything in the plan. Projects first (whose `compose
|
|
||||||
down` reaps their containers + networks atomically), then stray
|
|
||||||
legacy resources, then orphan state dirs."""
|
|
||||||
for project in plan.projects:
|
|
||||||
info(f"docker compose down ({project})")
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "compose", "-p", project, "down", "--volumes"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"compose down failed for {project}: "
|
|
||||||
f"{result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in plan.stray_containers:
|
|
||||||
info(f"removing stray container {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in plan.stray_networks:
|
|
||||||
info(f"removing stray network {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "network", "rm", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for identity in plan.orphan_state_dirs:
|
|
||||||
path = bottle_state_dir(identity)
|
|
||||||
info(f"removing orphan state dir {path}")
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
|
||||||
except OSError as e:
|
|
||||||
warn(f"failed to remove {path}: {e}")
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
|
|
||||||
|
|
||||||
`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
|
|
||||||
describing the per-bottle container topology — one project per
|
|
||||||
bottle instance, services for the agent + every applicable sidecar,
|
|
||||||
two networks, no named volumes.
|
|
||||||
|
|
||||||
Pure function. No I/O, no subprocess. Expects every launch-time
|
|
||||||
field (network names, CA host paths, etc.) on the plan's inner
|
|
||||||
plans to be populated; chunks 2+3 own that ordering.
|
|
||||||
|
|
||||||
Conditional services follow the plan content:
|
|
||||||
|
|
||||||
- agent + sidecars bundle: always.
|
|
||||||
- git-gate: iff plan.git_gate_plan.upstreams.
|
|
||||||
- egress: iff plan.egress_plan.routes.
|
|
||||||
- supervise: iff plan.supervise_plan is not None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ...egress import (
|
|
||||||
EGRESS_HOSTNAME,
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
|
||||||
from ...log import die, warn
|
|
||||||
from ...supervise import (
|
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
|
||||||
SUPERVISE_HOSTNAME,
|
|
||||||
SUPERVISE_PORT,
|
|
||||||
)
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from .bottle_plan import DockerBottlePlan
|
|
||||||
from .egress import (
|
|
||||||
EGRESS_CA_IN_CONTAINER,
|
|
||||||
EGRESS_PORT,
|
|
||||||
)
|
|
||||||
from .git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from . import network as network_mod
|
|
||||||
from .sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
sidecar_bundle_container_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Repo root or installed site-packages root, used as the build context for
|
|
||||||
# Dockerfiles that COPY bot_bottle source files.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_bundle_dockerfile() -> str:
|
|
||||||
if (Path(_REPO_DIR) / SIDECAR_BUNDLE_DOCKERFILE).is_file():
|
|
||||||
return SIDECAR_BUNDLE_DOCKERFILE
|
|
||||||
return f"bot_bottle/{SIDECAR_BUNDLE_DOCKERFILE}"
|
|
||||||
|
|
||||||
|
|
||||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Render a Compose v2 spec dict from a fully-resolved
|
|
||||||
DockerBottlePlan.
|
|
||||||
|
|
||||||
The plan must have its inner plans (`git_gate_plan`,
|
|
||||||
`egress_plan`, `supervise_plan`) populated with launch-time
|
|
||||||
fields — network names, CA host paths. The renderer doesn't
|
|
||||||
validate; callers feed it a fully-resolved plan or get an
|
|
||||||
incomplete compose spec back.
|
|
||||||
"""
|
|
||||||
project = f"bot-bottle-{plan.slug}"
|
|
||||||
services: dict[str, Any] = {
|
|
||||||
"sidecars": _sidecar_bundle_service(plan),
|
|
||||||
"agent": _agent_service(plan),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"name": project,
|
|
||||||
"services": services,
|
|
||||||
"networks": _networks(plan),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Compose-managed networks with explicit `name:` matching the
|
|
||||||
existing slug-suffixed convention. Compose creates them on `up`
|
|
||||||
and destroys them on `down`. The internal one is `--internal`
|
|
||||||
(no default gateway); the egress one is a normal user-defined
|
|
||||||
bridge."""
|
|
||||||
return {
|
|
||||||
"internal": {
|
|
||||||
"name": network_mod.network_name_for_slug(plan.slug),
|
|
||||||
"internal": True,
|
|
||||||
},
|
|
||||||
"egress": {
|
|
||||||
"name": network_mod.network_egress_name_for_slug(plan.slug),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
|
|
||||||
"""One bind-mount entry in the long-form `volumes:` shape.
|
|
||||||
Long form is preferred over `host:target:ro` strings because
|
|
||||||
it's easier to inspect in tests and survives whitespace in
|
|
||||||
host paths."""
|
|
||||||
return {
|
|
||||||
"type": "bind",
|
|
||||||
"source": str(host),
|
|
||||||
"target": target,
|
|
||||||
"read_only": read_only,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""The `sidecars` service: one container per bottle, bundle
|
|
||||||
image, all daemons under a Python init supervisor.
|
|
||||||
|
|
||||||
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
|
||||||
egress is always present; git-gate / supervise are conditional.
|
|
||||||
"""
|
|
||||||
daemons: list[str] = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons.append("git-gate")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
|
|
||||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
|
||||||
volumes: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
# --- egress -------------------------------------------------------
|
|
||||||
ep = plan.egress_plan
|
|
||||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
|
||||||
if ep.routes:
|
|
||||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
|
||||||
for token_env in sorted(ep.token_env_map.keys()):
|
|
||||||
env.append(token_env)
|
|
||||||
|
|
||||||
# --- git-gate -----------------------------------------------------
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if gp.upstreams:
|
|
||||||
volumes += [
|
|
||||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for u in gp.upstreams:
|
|
||||||
keypath = expand_tilde(u.identity_file)
|
|
||||||
volumes.append(_bind(
|
|
||||||
keypath,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
|
||||||
))
|
|
||||||
if u.known_hosts_file:
|
|
||||||
volumes.append(_bind(
|
|
||||||
u.known_hosts_file,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
|
||||||
))
|
|
||||||
|
|
||||||
# --- supervise ----------------------------------------------------
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
volumes.append({
|
|
||||||
"type": "bind",
|
|
||||||
"source": str(sp.queue_dir),
|
|
||||||
"target": QUEUE_DIR_IN_CONTAINER,
|
|
||||||
"read_only": False,
|
|
||||||
})
|
|
||||||
|
|
||||||
internal_aliases = [EGRESS_HOSTNAME]
|
|
||||||
if gp.upstreams:
|
|
||||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
|
||||||
if sp is not None:
|
|
||||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
|
||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
|
||||||
"build": {
|
|
||||||
"context": _REPO_DIR,
|
|
||||||
"dockerfile": _sidecar_bundle_dockerfile(),
|
|
||||||
},
|
|
||||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": internal_aliases},
|
|
||||||
"egress": None,
|
|
||||||
},
|
|
||||||
"environment": env,
|
|
||||||
"volumes": volumes,
|
|
||||||
}
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
|
||||||
exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
|
|
||||||
egress sidecar."""
|
|
||||||
proxy_url = _agent_proxy_url(plan)
|
|
||||||
no_proxy = _agent_no_proxy(plan)
|
|
||||||
env: list[str] = [
|
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
|
||||||
f"HTTP_PROXY={proxy_url}",
|
|
||||||
f"https_proxy={proxy_url}",
|
|
||||||
f"http_proxy={proxy_url}",
|
|
||||||
f"NO_PROXY={no_proxy}",
|
|
||||||
f"no_proxy={no_proxy}",
|
|
||||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
||||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
||||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
||||||
]
|
|
||||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
|
||||||
env.append(f"{name}={value}")
|
|
||||||
# Forwarded vars (OAuth token, manifest host-interpolations):
|
|
||||||
# bare name → inherits from compose-up process env, value
|
|
||||||
# never lands on argv or in the compose file.
|
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
|
||||||
env.append(name)
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
|
||||||
"image": plan.image,
|
|
||||||
"container_name": plan.container_name,
|
|
||||||
"command": ["sleep", "infinity"],
|
|
||||||
"networks": {"internal": None},
|
|
||||||
"environment": env,
|
|
||||||
}
|
|
||||||
if plan.use_runsc:
|
|
||||||
service["runtime"] = "runsc"
|
|
||||||
|
|
||||||
volumes: list[dict[str, Any]] = []
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
volumes.append(_bind(
|
|
||||||
plan.supervise_plan.current_config_dir,
|
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
||||||
))
|
|
||||||
if volumes:
|
|
||||||
service["volumes"] = volumes
|
|
||||||
|
|
||||||
# The init supervisor inside the bundle owns intra-bundle
|
|
||||||
# daemon ordering, so the agent only waits for the bundle
|
|
||||||
# container itself.
|
|
||||||
service["depends_on"] = ["sidecars"]
|
|
||||||
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
|
||||||
"""Agent's HTTP_PROXY — always points at egress."""
|
|
||||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
|
||||||
"""NO_PROXY for the agent: loopback always; supervise hostname
|
|
||||||
when the supervise sidecar is up (MCP long-poll must bypass
|
|
||||||
the egress proxy)."""
|
|
||||||
hosts = ["localhost", "127.0.0.1"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
hosts.append(SUPERVISE_HOSTNAME)
|
|
||||||
return ",".join(hosts)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
|
||||||
#
|
|
||||||
# The renderer above is pure. The helpers below own the I/O side:
|
|
||||||
# serialize the spec to disk, drive `docker compose up`, dump the
|
|
||||||
# merged log file on teardown, and `docker compose down` to clean up
|
|
||||||
# (networks are pre-created externally so `down` leaves them alone;
|
|
||||||
# the launch step removes them in its own teardown step).
|
|
||||||
|
|
||||||
|
|
||||||
COMPOSE_FILE_NAME = "docker-compose.yml"
|
|
||||||
COMPOSE_LOG_NAME = "compose.log"
|
|
||||||
|
|
||||||
|
|
||||||
COMPOSE_PROJECT_PREFIX = "bot-bottle-"
|
|
||||||
|
|
||||||
|
|
||||||
def compose_project_name(slug: str) -> str:
|
|
||||||
"""Stable mapping from slug → compose project. Matches the
|
|
||||||
`name:` field the renderer emits, so `docker compose ls`
|
|
||||||
enumeration and direct CLI invocations agree on the project
|
|
||||||
identifier."""
|
|
||||||
return f"{COMPOSE_PROJECT_PREFIX}{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def slug_from_compose_project(project: str) -> str:
|
|
||||||
"""Inverse of `compose_project_name`: strip the prefix to get
|
|
||||||
the underlying slug. Returns empty string if the project name
|
|
||||||
doesn't start with the expected prefix."""
|
|
||||||
if not project.startswith(COMPOSE_PROJECT_PREFIX):
|
|
||||||
return ""
|
|
||||||
return project[len(COMPOSE_PROJECT_PREFIX):]
|
|
||||||
|
|
||||||
|
|
||||||
def list_compose_projects(
|
|
||||||
*, include_stopped: bool = True, warn_on_error: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
"""All compose project names starting with `bot-bottle-`.
|
|
||||||
`include_stopped=True` (default) runs `docker compose ls --all`
|
|
||||||
so exited projects appear too; pass False to get only projects
|
|
||||||
with at least one running container.
|
|
||||||
|
|
||||||
Returns [] on docker daemon errors or malformed output rather
|
|
||||||
than raising — callers should treat the empty list as "no
|
|
||||||
projects discoverable", not "no projects exist". `warn_on_error`
|
|
||||||
stays true for explicit operator commands like cleanup, but active
|
|
||||||
discovery paths set it false so dashboard refreshes don't spam
|
|
||||||
stderr while Docker Desktop is stopped."""
|
|
||||||
argv = ["docker", "compose", "ls", "--format", "json"]
|
|
||||||
if include_stopped:
|
|
||||||
argv.insert(3, "--all")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# docker binary not on PATH — same shape as a daemon-down
|
|
||||||
# error from the caller's POV: no projects discoverable.
|
|
||||||
return []
|
|
||||||
if result.returncode != 0:
|
|
||||||
if warn_on_error:
|
|
||||||
warn(f"docker compose ls failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
projects = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
if warn_on_error:
|
|
||||||
warn(f"docker compose ls returned malformed JSON: {e}")
|
|
||||||
return []
|
|
||||||
names: list[str] = []
|
|
||||||
for p in projects:
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
continue
|
|
||||||
name = str(p.get("Name", ""))
|
|
||||||
if name.startswith(COMPOSE_PROJECT_PREFIX):
|
|
||||||
names.append(name)
|
|
||||||
return sorted(set(names))
|
|
||||||
|
|
||||||
|
|
||||||
def list_active_slugs(
|
|
||||||
*, include_stopped: bool = False, warn_on_error: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Slugs (project name minus prefix) of currently-running
|
|
||||||
bottles. Used by the dashboard's operator-edit verbs to choose
|
|
||||||
a bottle to apply a config edit to."""
|
|
||||||
return sorted(
|
|
||||||
slug for slug in (
|
|
||||||
slug_from_compose_project(p)
|
|
||||||
for p in list_compose_projects(
|
|
||||||
include_stopped=include_stopped,
|
|
||||||
warn_on_error=warn_on_error,
|
|
||||||
)
|
|
||||||
) if slug
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compose_file_path(state_dir: Path) -> Path:
|
|
||||||
return state_dir / COMPOSE_FILE_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def compose_log_path(state_dir: Path) -> Path:
|
|
||||||
return state_dir / COMPOSE_LOG_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def write_compose_file(spec: dict[str, Any], path: Path) -> Path:
|
|
||||||
"""Serialize the compose dict to disk. JSON content with a
|
|
||||||
`.yml` filename — JSON is a strict subset of YAML 1.2 for the
|
|
||||||
constructs the renderer uses (mappings, lists, strings, bools,
|
|
||||||
nulls), and `docker compose -f file.yml` parses it as YAML.
|
|
||||||
Avoids a yaml dependency while keeping the file `cat`-readable.
|
|
||||||
"""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(spec, indent=2, sort_keys=False) + "\n")
|
|
||||||
path.chmod(0o644)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _compose_argv(project: str, compose_file: Path, *cmd: str) -> list[str]:
|
|
||||||
return [
|
|
||||||
"docker", "compose",
|
|
||||||
"-p", project,
|
|
||||||
"-f", str(compose_file),
|
|
||||||
*cmd,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def compose_up(
|
|
||||||
project: str,
|
|
||||||
compose_file: Path,
|
|
||||||
*,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""`docker compose up -d` for the project. Env-inheritance is
|
|
||||||
via `env=` on the subprocess — every `environment: [NAME]` (bare
|
|
||||||
name) entry in the compose file resolves to whatever value
|
|
||||||
`NAME` has in `env` at exec time. Secrets never land on argv or
|
|
||||||
in the compose file."""
|
|
||||||
argv = _compose_argv(project, compose_file, "up", "-d")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
sys.stderr.write(result.stderr)
|
|
||||||
die(f"docker compose up failed for project {project}")
|
|
||||||
|
|
||||||
|
|
||||||
def compose_dump_logs(project: str, compose_file: Path, output: Path) -> None:
|
|
||||||
"""Write the merged stdout/stderr of every service to `output`
|
|
||||||
using `docker compose logs --no-color --timestamps`. Best-effort
|
|
||||||
— failures here shouldn't block teardown. The interleaved single
|
|
||||||
file is what the user reads post-mortem; per-service tail still
|
|
||||||
works through `docker compose logs -f <service>` while the
|
|
||||||
project is up."""
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
argv = _compose_argv(project, compose_file, "logs", "--no-color", "--timestamps")
|
|
||||||
try:
|
|
||||||
with open(output, "wb") as f:
|
|
||||||
subprocess.run(
|
|
||||||
argv,
|
|
||||||
stdout=f,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
output.chmod(0o644)
|
|
||||||
except OSError as e:
|
|
||||||
warn(f"failed to write compose log to {output}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def compose_down(project: str, compose_file: Path) -> None:
|
|
||||||
"""`docker compose down` for the project. External networks are
|
|
||||||
intentionally NOT removed by compose (`external: true` on the
|
|
||||||
networks block); the launch step's own teardown removes them
|
|
||||||
via `network_remove` so the per-bottle ephemeral subnet doesn't
|
|
||||||
accumulate."""
|
|
||||||
argv = _compose_argv(project, compose_file, "down")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"docker compose down failed for project {project}: "
|
|
||||||
f"{result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"COMPOSE_FILE_NAME",
|
|
||||||
"COMPOSE_LOG_NAME",
|
|
||||||
"COMPOSE_PROJECT_PREFIX",
|
|
||||||
"bottle_plan_to_compose",
|
|
||||||
"compose_down",
|
|
||||||
"compose_dump_logs",
|
|
||||||
"compose_file_path",
|
|
||||||
"compose_log_path",
|
|
||||||
"compose_project_name",
|
|
||||||
"compose_up",
|
|
||||||
"list_active_slugs",
|
|
||||||
"list_compose_projects",
|
|
||||||
"slug_from_compose_project",
|
|
||||||
"write_compose_file",
|
|
||||||
]
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Docker-side egress helpers: port pin, in-container CA paths,
|
|
||||||
container naming, and the host-side mitmproxy CA mint. The
|
|
||||||
prepare-time routes-yaml rendering itself lives on the
|
|
||||||
platform-neutral `Egress` ABC — backends instantiate it directly.
|
|
||||||
|
|
||||||
The per-container `.start()` / `.stop()` lifecycle was removed in
|
|
||||||
PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
|
|
||||||
under its python init supervisor."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...log import die
|
|
||||||
|
|
||||||
|
|
||||||
# Listening port the egress daemon binds inside the bundle. The
|
|
||||||
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
|
||||||
# and the bundle's network aliases route `egress` to itself.
|
|
||||||
EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099"))
|
|
||||||
|
|
||||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
|
||||||
# file holding BOTH the cert and the private key, concatenated.
|
|
||||||
EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|
||||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
|
||||||
|
|
||||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
|
||||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
|
||||||
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
|
|
||||||
- `cert_only_pem` is the cert alone — installed into the agent's
|
|
||||||
trust store by `provision_ca` so the agent trusts the bumped
|
|
||||||
CONNECT cert egress presents.
|
|
||||||
|
|
||||||
openssl req's `subjectKeyIdentifier=hash` extension uses
|
|
||||||
SHA-1(pubkey), matching mitmproxy's AKI computation on leaves.
|
|
||||||
|
|
||||||
Both files live under `<stage_dir>/egress-ca/` (mode 644 —
|
|
||||||
`docker cp` preserves the mode into the container, where the
|
|
||||||
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
|
||||||
mode 700 so the private key isn't world-exposed)."""
|
|
||||||
work = stage_dir / "egress-ca"
|
|
||||||
work.mkdir(exist_ok=True)
|
|
||||||
key_path = work / "ca-key.pem"
|
|
||||||
cert_path = work / "ca.pem"
|
|
||||||
cnf_path = work / "ca.cnf"
|
|
||||||
|
|
||||||
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
|
|
||||||
# config matches RSA CAs without surprise), and openssl req's
|
|
||||||
# default behavior here is exactly what we want.
|
|
||||||
keygen = subprocess.run(
|
|
||||||
["openssl", "genrsa", "-out", str(key_path), "2048"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if keygen.returncode != 0:
|
|
||||||
die(f"egress ca keygen failed: {keygen.stderr.strip()}")
|
|
||||||
# Standalone private key — never docker-cp'd, never bind-mounted
|
|
||||||
# (mitmproxy reads the cert+key concat below). Lock to owner-
|
|
||||||
# only so it doesn't sit at the default umask on disk.
|
|
||||||
key_path.chmod(0o600)
|
|
||||||
|
|
||||||
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
|
||||||
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
|
||||||
# leaves it later mints. Without this, chain validation breaks
|
|
||||||
# despite the CA being present in the trust store.
|
|
||||||
cnf_path.write_text(
|
|
||||||
"[req]\n"
|
|
||||||
"distinguished_name = req_dn\n"
|
|
||||||
"prompt = no\n"
|
|
||||||
"x509_extensions = v3_ca\n"
|
|
||||||
"\n"
|
|
||||||
"[req_dn]\n"
|
|
||||||
"O = bot-bottle\n"
|
|
||||||
"CN = bot-bottle egress CA\n"
|
|
||||||
"\n"
|
|
||||||
"[v3_ca]\n"
|
|
||||||
"basicConstraints = critical, CA:TRUE\n"
|
|
||||||
"keyUsage = critical, keyCertSign, cRLSign\n"
|
|
||||||
"subjectKeyIdentifier = hash\n"
|
|
||||||
)
|
|
||||||
cnf_path.chmod(0o644)
|
|
||||||
|
|
||||||
req = subprocess.run(
|
|
||||||
["openssl", "req", "-x509", "-new", "-nodes",
|
|
||||||
"-key", str(key_path),
|
|
||||||
"-sha256", "-days", "365",
|
|
||||||
"-config", str(cnf_path),
|
|
||||||
"-out", str(cert_path)],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if req.returncode != 0:
|
|
||||||
die(f"egress ca cert generation failed: {req.stderr.strip()}")
|
|
||||||
|
|
||||||
cert_path.chmod(0o644)
|
|
||||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
|
||||||
# This file IS bind-mounted into the egress container (chunk 3+),
|
|
||||||
# where mitmproxy runs as uid 1000 — so the host file has to be
|
|
||||||
# world-readable for the container's user to read it through the
|
|
||||||
# mount. Owner-only mode on the parent dir (state/<slug>/, under
|
|
||||||
# ~/.bot-bottle which inherits ~'s 0o700) is what actually
|
|
||||||
# restricts who can reach this file on the host.
|
|
||||||
mitm = work / "mitmproxy-ca.pem"
|
|
||||||
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
|
||||||
mitm.chmod(0o644)
|
|
||||||
return (mitm, cert_path)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Host-side helper for egress sidecar inspection and live updates.
|
|
||||||
|
|
||||||
The approve path uses this module to validate a proposed routes file,
|
|
||||||
write it to the bottle's live egress state dir, and signal the sidecar
|
|
||||||
bundle so the mitmproxy addon reloads it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
|
||||||
container = sidecar_bundle_container_name(slug)
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not read routes.yaml from {container}: "
|
|
||||||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
|
||||||
)
|
|
||||||
return r.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressApplicator(EgressApplicator):
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
|
||||||
container = sidecar_bundle_container_name(slug)
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = DockerEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
|
||||||
]
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Active-agent enumeration for the docker backend.
|
|
||||||
|
|
||||||
Mirrors `backend/smolmachines/enumerate.py`: returns
|
|
||||||
`ActiveAgent` records the CLI `list active` command and the
|
|
||||||
dashboard agents pane consume. Empty when docker isn't reachable
|
|
||||||
— gated by `has_backend('docker')` at the cross-backend caller
|
|
||||||
so this module trusts that docker is available when called.
|
|
||||||
|
|
||||||
The parser (`_parse_services_by_project`) is exposed for direct
|
|
||||||
unit testing; the docker `docker ps` invocation is in
|
|
||||||
`_query_services_by_project`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .compose import compose_project_name, list_active_slugs
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
|
||||||
"""All currently-running docker-backed agents. Caller is
|
|
||||||
responsible for gating on `has_backend('docker')` if it
|
|
||||||
matters; if docker is missing the `docker ps` call below
|
|
||||||
returns an empty list silently."""
|
|
||||||
slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
|
|
||||||
if not slugs:
|
|
||||||
return []
|
|
||||||
services_by_project = _query_services_by_project()
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for slug in slugs:
|
|
||||||
project = compose_project_name(slug)
|
|
||||||
services = services_by_project.get(project, set())
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="docker",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "?",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=tuple(sorted(services)),
|
|
||||||
label=metadata.label if metadata else "",
|
|
||||||
color=metadata.color if metadata else "",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_services_by_project(stdout: str) -> dict[str, set[str]]:
|
|
||||||
"""Parse `docker ps` output formatted as
|
|
||||||
`<project-label>\\t<service-label>` (one line per container)
|
|
||||||
into a `{project: {service, ...}}` mapping. Pure function for
|
|
||||||
testing — the docker invocation is in `_query_services_by_project`."""
|
|
||||||
out: dict[str, set[str]] = {}
|
|
||||||
for line in stdout.splitlines():
|
|
||||||
project, _, service = line.partition("\t")
|
|
||||||
if not project or not service:
|
|
||||||
continue
|
|
||||||
out.setdefault(project, set()).add(service)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _query_services_by_project() -> dict[str, set[str]]:
|
|
||||||
"""One `docker ps` call → `{project: {service, ...}}`. Used
|
|
||||||
by the CLI's `list active` and the dashboard's agents pane —
|
|
||||||
one subprocess per refresh tick, not one per bottle."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "ps",
|
|
||||||
"--filter", "label=com.docker.compose.project",
|
|
||||||
"--format",
|
|
||||||
'{{.Label "com.docker.compose.project"}}'
|
|
||||||
"\t"
|
|
||||||
'{{.Label "com.docker.compose.service"}}',
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {}
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {}
|
|
||||||
return _parse_services_by_project(r.stdout or "")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class DockerFreezer(Freezer):
|
|
||||||
"""Freezes a Docker bottle by running `docker commit`."""
|
|
||||||
|
|
||||||
backend_name = "docker"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Docker-side git-gate constants: in-container paths the renderer's
|
|
||||||
bind-mounts target + the listening port. The prepare-time entrypoint
|
|
||||||
/ hook render lives on the platform-neutral `GitGate` ABC — backends
|
|
||||||
instantiate it directly. The git-gate daemon's container lifecycle
|
|
||||||
is owned by the sidecar bundle (PRD 0024)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
|
||||||
|
|
||||||
# git daemon's default listening port.
|
|
||||||
GIT_GATE_PORT = 9418
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"""Launch step for the Docker bottle backend.
|
|
||||||
|
|
||||||
PRD 0018 chunk 3: each instance is one `docker compose` project.
|
|
||||||
|
|
||||||
The flow is:
|
|
||||||
|
|
||||||
1. Build the agent image from the provider Dockerfile (compose
|
|
||||||
builds the sidecar images via the `build:` directive on first up).
|
|
||||||
2. Mint the per-bottle egress CA (chunk 2 writes it under
|
|
||||||
state/<slug>/egress/).
|
|
||||||
3. Populate the inner plans with launch-time fields so the
|
|
||||||
renderer can read network names, CA paths.
|
|
||||||
6. Render the compose spec, write it to
|
|
||||||
state/<slug>/docker-compose.yml, write metadata.json.
|
|
||||||
7. `docker compose up -d` (token + OAuth values flow into the
|
|
||||||
compose subprocess env so `environment: [NAME]` bare-name
|
|
||||||
entries inherit without rendering values into the file).
|
|
||||||
8. Provision (CA install, prompt copy, skills, workspace, git,
|
|
||||||
supervise config) — unchanged, uses `docker exec` / `docker cp`.
|
|
||||||
9. Yield a DockerBottle handle. `exec_agent` runs claude via
|
|
||||||
`docker exec -it` exactly like the pre-compose world.
|
|
||||||
|
|
||||||
Teardown (ExitStack callbacks fire in reverse):
|
|
||||||
- Dump `docker compose logs --no-color --timestamps` to
|
|
||||||
state/<slug>/compose.log (best-effort).
|
|
||||||
- `docker compose down` removes the project's containers (not the
|
|
||||||
external networks).
|
|
||||||
- `network_remove` deletes the two networks we pre-created.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
|
||||||
|
|
||||||
from ...egress import egress_resolve_token_values
|
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
|
||||||
from ...log import info, warn
|
|
||||||
from . import network as network_mod
|
|
||||||
from . import util as docker_mod
|
|
||||||
from .bottle import DockerBottle
|
|
||||||
from .bottle_plan import DockerBottlePlan
|
|
||||||
from ...bottle_state import (
|
|
||||||
bottle_state_dir,
|
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from .compose import (
|
|
||||||
bottle_plan_to_compose,
|
|
||||||
compose_down,
|
|
||||||
compose_dump_logs,
|
|
||||||
compose_file_path,
|
|
||||||
compose_log_path,
|
|
||||||
compose_project_name,
|
|
||||||
compose_up,
|
|
||||||
write_compose_file,
|
|
||||||
)
|
|
||||||
from .egress import egress_tls_init
|
|
||||||
|
|
||||||
|
|
||||||
# Where the repo root lives, for `docker build` context. Computed once.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
plan: DockerBottlePlan,
|
|
||||||
*,
|
|
||||||
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
|
|
||||||
) -> Generator[DockerBottle, None, None]:
|
|
||||||
"""Build, launch, and provision a Docker bottle via compose.
|
|
||||||
Teardown on exit."""
|
|
||||||
stack = ExitStack()
|
|
||||||
|
|
||||||
_bottle_for_revoke = plan.manifest.bottle
|
|
||||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
|
||||||
|
|
||||||
def teardown() -> None:
|
|
||||||
try:
|
|
||||||
stack.close()
|
|
||||||
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
|
||||||
warn(
|
|
||||||
f"teardown failed for container {plan.container_name}"
|
|
||||||
f" (compose-down): {exc!r}"
|
|
||||||
)
|
|
||||||
revoke_git_gate_provisioned_keys(
|
|
||||||
_bottle_for_revoke, _git_gate_dir_for_revoke
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Step 1: agent image. Use a committed snapshot when one exists
|
|
||||||
# and is present in the local daemon; otherwise build from the
|
|
||||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
|
||||||
# up` via the renderer's `build:` directives.
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and docker_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
plan = dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
docker_mod.build_image(
|
|
||||||
plan.image, _REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
|
||||||
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
|
|
||||||
git_gate_plan = plan.git_gate_plan
|
|
||||||
if git_gate_plan.upstreams:
|
|
||||||
git_gate_plan = dataclasses.replace(
|
|
||||||
git_gate_plan,
|
|
||||||
internal_network=internal_network,
|
|
||||||
egress_network=egress_network,
|
|
||||||
)
|
|
||||||
egress_plan = dataclasses.replace(
|
|
||||||
plan.egress_plan,
|
|
||||||
internal_network=internal_network,
|
|
||||||
egress_network=egress_network,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
|
||||||
supervise_plan = plan.supervise_plan
|
|
||||||
if supervise_plan is not None:
|
|
||||||
supervise_plan = dataclasses.replace(
|
|
||||||
supervise_plan,
|
|
||||||
internal_network=internal_network,
|
|
||||||
)
|
|
||||||
plan = dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 6: render + write the compose file. metadata.json
|
|
||||||
# was written at prepare time and already carries
|
|
||||||
# compose_project; nothing to update here.
|
|
||||||
state_dir = bottle_state_dir(plan.slug)
|
|
||||||
spec = bottle_plan_to_compose(plan)
|
|
||||||
compose_file = write_compose_file(spec, compose_file_path(state_dir))
|
|
||||||
project = compose_project_name(plan.slug)
|
|
||||||
|
|
||||||
# Step 7: compose up. Token values + the OAuth placeholder
|
|
||||||
# flow through subprocess env; the compose file holds only
|
|
||||||
# bare names for the secret-carrying entries.
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
compose_env: dict[str, str] = {
|
|
||||||
**os.environ,
|
|
||||||
**plan.forwarded_env,
|
|
||||||
**token_values,
|
|
||||||
}
|
|
||||||
info(
|
|
||||||
f"docker compose up -d (project {project}, "
|
|
||||||
f"{len(spec['services'])} services)"
|
|
||||||
)
|
|
||||||
compose_up(project, compose_file, env=compose_env)
|
|
||||||
|
|
||||||
# Register teardown in reverse order: log dump first, then
|
|
||||||
# `compose down`. Networks come down last via callbacks
|
|
||||||
# registered in step 2.
|
|
||||||
stack.callback(compose_down, project, compose_file)
|
|
||||||
stack.callback(
|
|
||||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 8: provision. Create the bottle first so provisioners
|
|
||||||
# can use bottle.exec / bottle.cp_in; set the prompt path
|
|
||||||
# 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,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
|
||||||
# — the agent runs `sleep infinity` per the renderer's
|
|
||||||
# service spec.
|
|
||||||
yield bottle
|
|
||||||
finally:
|
|
||||||
teardown()
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""Backend-infrastructure provisioners for the Docker backend.
|
|
||||||
|
|
||||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
|
||||||
declarative provision-plan apply, supervise MCP registration) live on
|
|
||||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
|
|
||||||
provisioning also moved to the AgentProvider ABC (with Debian/node
|
|
||||||
defaults); user plugins override them for non-standard images.
|
|
||||||
|
|
||||||
No modules remain in this subpackage — the directory is kept so that
|
|
||||||
existing imports of `from .provision import ...` don't need updating
|
|
||||||
if new backend-specific provisioners are added later.
|
|
||||||
"""
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Prepare step for the Docker bottle backend.
|
|
||||||
|
|
||||||
`resolve_plan` does all host-side resolution (image and container
|
|
||||||
names, prompt-file, proxy plan, runtime detection) and returns a
|
|
||||||
frozen DockerBottlePlan. No Docker resources are created; the only
|
|
||||||
side effects are scratch files under `stage_dir` and a probe of
|
|
||||||
`docker info`. Cross-backend host-side validation has already run
|
|
||||||
via the base class's `prepare` template before this is called.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from . import util as docker_mod
|
|
||||||
from .bottle_plan import DockerBottlePlan
|
|
||||||
from .. import BottleSpec
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
|
|
||||||
def preflight() -> None:
|
|
||||||
docker_mod.require_docker()
|
|
||||||
|
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return dict(resolved_env.literals)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
|
||||||
spec: BottleSpec,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> DockerBottlePlan:
|
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
|
||||||
that the agent and its skills/git-gate keys are present —
|
|
||||||
validation already ran in the base class."""
|
|
||||||
|
|
||||||
# ==== docker specific setup ====
|
|
||||||
use_runsc = docker_mod.runsc_available()
|
|
||||||
|
|
||||||
return DockerBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
manifest=manifest,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug=slug,
|
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
use_runsc=use_runsc,
|
|
||||||
agent_provision=agent_provision_plan,
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""Sidecar bundle constants + helpers for the Docker backend
|
|
||||||
(PRD 0024).
|
|
||||||
|
|
||||||
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
|
|
||||||
runs egress + git-gate + supervise as one container per bottle
|
|
||||||
under a small Python init supervisor. As of chunk 5 the bundle
|
|
||||||
is the only shape — the legacy four-sidecar topology and its
|
|
||||||
`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
# Bundle image. Defaults to a built-locally tag. Source checkouts
|
|
||||||
# build from the repo-root Dockerfile.sidecars; installed packages
|
|
||||||
# build from the packaged copy under bot_bottle/.
|
|
||||||
# Operators pinning to a published digest can override via env.
|
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
|
||||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
|
||||||
"bot-bottle-sidecars:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_bundle_container_name(slug: str) -> str:
|
|
||||||
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
|
|
||||||
per-sidecar containers it replaces, so the dashboard's
|
|
||||||
discovery-by-prefix logic keeps working."""
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Shared base class for host-side egress apply across backends.
|
|
||||||
|
|
||||||
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
|
|
||||||
with the backend-specific kill command.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..bottle_state import egress_state_dir
|
|
||||||
from ..egress import EGRESS_ROUTES_FILENAME
|
|
||||||
from ..egress_addon_core import load_routes
|
|
||||||
|
|
||||||
|
|
||||||
class EgressApplyError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class EgressApplicator(ABC):
|
|
||||||
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
|
|
||||||
"""Persist `content` to the live routes file and reload egress."""
|
|
||||||
self.validate_routes_content(content)
|
|
||||||
routes_path = self._routes_path(slug)
|
|
||||||
routes_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
|
|
||||||
routes_path.write_text(content, encoding="utf-8")
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
self._signal_bundle_reload(slug)
|
|
||||||
return before, content
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_routes_content(content: str) -> None:
|
|
||||||
try:
|
|
||||||
load_routes(content)
|
|
||||||
except ValueError as e:
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"proposed routes.yaml is not valid: {e}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _routes_path(slug: str) -> Path:
|
|
||||||
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EgressApplicator", "EgressApplyError"]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""Freezer — snapshot a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
Follows the same pattern as BottleBackend: a shared base class with
|
|
||||||
common post-freeze steps (write committed-image path, mark preserved,
|
|
||||||
print resume hint) and backend-specific subclasses in their respective
|
|
||||||
backend directories.
|
|
||||||
|
|
||||||
Entry points:
|
|
||||||
Freezer.commit(agent) — freeze by ActiveAgent
|
|
||||||
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
|
|
||||||
get_freezer(backend_name) — factory
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from . import ActiveAgent
|
|
||||||
from ..bottle_state import mark_preserved, write_committed_image
|
|
||||||
from ..log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
class CommitCancelled(Exception):
|
|
||||||
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
|
|
||||||
|
|
||||||
|
|
||||||
class Freezer(ABC):
|
|
||||||
"""Freezes a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
The base class owns the shared post-commit steps:
|
|
||||||
- write_committed_image — records the artifact path in per-bottle state
|
|
||||||
- mark_preserved — prevents teardown from removing the state dir
|
|
||||||
- resume hint — printed to stderr after the snapshot
|
|
||||||
|
|
||||||
Subclasses implement _freeze with the backend-specific snapshot
|
|
||||||
operation and optionally override _export_hint for migration hints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
backend_name: str
|
|
||||||
|
|
||||||
def commit(self, agent: ActiveAgent) -> None:
|
|
||||||
"""Freeze the bottle for `agent` to a resumable artifact.
|
|
||||||
|
|
||||||
Calls _freeze for the backend-specific snapshot, then writes the
|
|
||||||
committed image reference to per-bottle state and marks the bottle
|
|
||||||
preserved so the next `./cli.py resume` boots from the snapshot.
|
|
||||||
|
|
||||||
Raises CommitCancelled if the user declines an interactive
|
|
||||||
confirmation prompt (e.g. the macos-container stop prompt).
|
|
||||||
"""
|
|
||||||
image_ref = self._freeze(agent)
|
|
||||||
write_committed_image(agent.slug, image_ref)
|
|
||||||
mark_preserved(agent.slug)
|
|
||||||
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
|
|
||||||
self._export_hint(agent.slug, image_ref)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
"""Backend-specific snapshot. Returns the image tag or artifact path
|
|
||||||
stored by write_committed_image. Raises CommitCancelled if the user
|
|
||||||
declines a stop-confirmation prompt."""
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
"""Optionally print an export-for-migration hint after committing.
|
|
||||||
Overridden by backends that provide a meaningful export command."""
|
|
||||||
|
|
||||||
def commit_slug(self, slug: str) -> None:
|
|
||||||
"""Convenience entry for cmd_commit when only a slug is available."""
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
agent = ActiveAgent(
|
|
||||||
backend_name=self.backend_name,
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
)
|
|
||||||
self.commit(agent)
|
|
||||||
|
|
||||||
|
|
||||||
def get_freezer(backend_name: str) -> Freezer:
|
|
||||||
"""Return the Freezer for the named backend.
|
|
||||||
|
|
||||||
backend_name "" is treated as "docker" for backward compatibility
|
|
||||||
with state dirs written before the backend field was added."""
|
|
||||||
resolved = backend_name or "docker"
|
|
||||||
if resolved == "docker":
|
|
||||||
from .docker.freezer import DockerFreezer
|
|
||||||
return DockerFreezer()
|
|
||||||
if resolved == "macos-container":
|
|
||||||
from .macos_container.freezer import MacosContainerFreezer
|
|
||||||
return MacosContainerFreezer()
|
|
||||||
if resolved == "smolmachines":
|
|
||||||
from .smolmachines.freezer import SmolmachinesFreezer
|
|
||||||
return SmolmachinesFreezer()
|
|
||||||
die(
|
|
||||||
f"commit is only supported for docker, macos-container, and "
|
|
||||||
f"smolmachines; backend {backend_name!r} has no freezer"
|
|
||||||
)
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""macOS Apple Container backend.
|
|
||||||
|
|
||||||
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
|
||||||
the Apple `container` CLI integration; launch remains gated until the
|
|
||||||
sidecar network enforcement shape is implemented.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .backend import MacosContainerBottleBackend
|
|
||||||
|
|
||||||
__all__ = ["MacosContainerBottleBackend"]
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""MacosContainerBottleBackend — Apple Container implementation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Generator, Sequence
|
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
|
||||||
from . import cleanup as _cleanup
|
|
||||||
from . import enumerate as _enumerate
|
|
||||||
from . import launch as _launch
|
|
||||||
from . import resolve_plan as _resolve_plan
|
|
||||||
from . import util as _container
|
|
||||||
from .bottle import MacosContainerBottle
|
|
||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerBottleBackend(
|
|
||||||
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
|
|
||||||
):
|
|
||||||
"""Apple Container backend. Selected by
|
|
||||||
`BOT_BOTTLE_BACKEND=macos-container` or
|
|
||||||
`--backend=macos-container`."""
|
|
||||||
|
|
||||||
name = "macos-container"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
return _container.is_available()
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
_resolve_plan.preflight()
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return _resolve_plan.build_guest_env(resolved_env)
|
|
||||||
|
|
||||||
def _resolve_plan(
|
|
||||||
self,
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
return _resolve_plan.resolve_plan(
|
|
||||||
spec,
|
|
||||||
manifest=manifest,
|
|
||||||
slug=slug,
|
|
||||||
resolved_env=resolved_env,
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
self, plan: MacosContainerBottlePlan
|
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
|
||||||
yield bottle
|
|
||||||
|
|
||||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
|
||||||
return _cleanup.prepare_cleanup()
|
|
||||||
|
|
||||||
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
|
|
||||||
_cleanup.cleanup(plan)
|
|
||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
return _enumerate.enumerate_active()
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
|
||||||
return plan.agent_supervise_url
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Bottle handle for Apple's `container` CLI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from typing import Callable, cast
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
|
||||||
from .. import Bottle, ExecResult
|
|
||||||
from ..terminal import exec_shell_script
|
|
||||||
from . import pty_forward as _pty_forward
|
|
||||||
|
|
||||||
|
|
||||||
_PTY_FORWARD_SCRIPT = _pty_forward.__file__
|
|
||||||
_TERMINAL_ENV_NAMES = (
|
|
||||||
"TERM",
|
|
||||||
"COLORTERM",
|
|
||||||
"TERM_PROGRAM",
|
|
||||||
"TERM_PROGRAM_VERSION",
|
|
||||||
"KITTY_WINDOW_ID",
|
|
||||||
"KITTY_PID",
|
|
||||||
"WEZTERM_PANE",
|
|
||||||
"WEZTERM_UNIX_SOCKET",
|
|
||||||
"GHOSTTY_BIN_DIR",
|
|
||||||
"GHOSTTY_RESOURCES_DIR",
|
|
||||||
"ITERM_SESSION_ID",
|
|
||||||
"VTE_VERSION",
|
|
||||||
"KONSOLE_VERSION",
|
|
||||||
"ALACRITTY_WINDOW_ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _terminal_env_names() -> tuple[str, ...]:
|
|
||||||
return tuple(
|
|
||||||
name for name in _TERMINAL_ENV_NAMES
|
|
||||||
if name == "TERM" or os.environ.get(name)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerBottle(Bottle):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
container: str,
|
|
||||||
teardown: Callable[[], None],
|
|
||||||
prompt_path_in_container: str | None,
|
|
||||||
*,
|
|
||||||
agent_command: str = "claude",
|
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
terminal_title: str = "",
|
|
||||||
terminal_color: str = "",
|
|
||||||
agent_workdir: str = "/home/node",
|
|
||||||
):
|
|
||||||
self.name = container
|
|
||||||
self._teardown = teardown
|
|
||||||
self.prompt_path = prompt_path_in_container
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
|
||||||
self.agent_command = agent_command
|
|
||||||
self.terminal_title = terminal_title
|
|
||||||
self.terminal_color = terminal_color
|
|
||||||
self.agent_provider_template = agent_provider_template
|
|
||||||
self.agent_workdir = agent_workdir
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
|
||||||
full_argv = list(argv)
|
|
||||||
full_argv.extend(
|
|
||||||
prompt_args(
|
|
||||||
cast(PromptMode, self._agent_prompt_mode),
|
|
||||||
self.prompt_path,
|
|
||||||
argv=full_argv,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
container_exec = ["container", "exec"]
|
|
||||||
if tty:
|
|
||||||
container_exec.extend(["--interactive", "--tty"])
|
|
||||||
# Forward terminal capability hints so TUIs can enable modified-key
|
|
||||||
# protocols. Use bare env names: values stay in the child env, not
|
|
||||||
# on argv, and pty_forward supplies a TERM fallback when needed.
|
|
||||||
for name in _terminal_env_names():
|
|
||||||
container_exec.extend(["--env", name])
|
|
||||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
|
||||||
container_exec.extend(["--workdir", self.agent_workdir])
|
|
||||||
container_exec.extend([self.name, self.agent_command, *full_argv])
|
|
||||||
if tty:
|
|
||||||
# Wrap with the raw-mode forwarder: container exec does not put
|
|
||||||
# the host terminal into raw mode itself, so the line discipline
|
|
||||||
# buffers modifier-key sequences until CR. The wrapper sets raw
|
|
||||||
# mode before exec and restores it on exit.
|
|
||||||
return [sys.executable, _PTY_FORWARD_SCRIPT, "--", *container_exec]
|
|
||||||
return container_exec
|
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
|
||||||
script = (
|
|
||||||
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
|
||||||
if tty else None
|
|
||||||
)
|
|
||||||
if script is None:
|
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
|
||||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "exec", "--user", user, "--interactive",
|
|
||||||
self.name, "sh", "-s"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return ExecResult(
|
|
||||||
returncode=result.returncode,
|
|
||||||
stdout=result.stdout,
|
|
||||||
stderr=result.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["container", "cp", host_path, f"{self.name}:{container_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._closed:
|
|
||||||
return
|
|
||||||
self._closed = True
|
|
||||||
self._teardown()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Cleanup plan for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from ...log import info
|
|
||||||
from .. import BottleCleanupPlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
|
||||||
containers: tuple[str, ...] = ()
|
|
||||||
networks: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
if not self.containers and not self.networks:
|
|
||||||
info("macos-container cleanup: nothing to remove")
|
|
||||||
return
|
|
||||||
for name in self.containers:
|
|
||||||
info(f"macos-container container: {name}")
|
|
||||||
for name in self.networks:
|
|
||||||
info(f"macos-container network: {name}")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
return not self.containers and not self.networks
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Plan type for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode
|
|
||||||
from .. import BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MacosContainerBottlePlan(BottlePlan):
|
|
||||||
slug: str
|
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
|
||||||
agent_proxy_url: str = ""
|
|
||||||
agent_git_gate_url: str = ""
|
|
||||||
agent_supervise_url: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_name(self) -> str:
|
|
||||||
return self.agent_provision.instance_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image(self) -> str:
|
|
||||||
return self.agent_provision.image
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dockerfile_path(self) -> str:
|
|
||||||
return self.agent_provision.dockerfile
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_file(self) -> Path:
|
|
||||||
return self.agent_provision.prompt_file
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_command(self) -> str:
|
|
||||||
return self.agent_provision.command
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_prompt_mode(self) -> PromptMode:
|
|
||||||
return self.agent_provision.prompt_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_provider_template(self) -> str:
|
|
||||||
return self.agent_provision.template
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_host(self) -> str:
|
|
||||||
if self.agent_git_gate_url.startswith("http://"):
|
|
||||||
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
|
|
||||||
return super().git_gate_insteadof_host
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_scheme(self) -> str:
|
|
||||||
if self.agent_git_gate_url.startswith("http://"):
|
|
||||||
return "http"
|
|
||||||
return super().git_gate_insteadof_scheme
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Cleanup for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import info, warn
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "list", "--all", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(f"container list failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
|
||||||
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_networks() -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "network", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
|
||||||
if name.startswith(_PREFIX)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
|
||||||
container_mod.require_container()
|
|
||||||
return MacosContainerBottleCleanupPlan(
|
|
||||||
containers=tuple(_list_prefixed_containers()),
|
|
||||||
networks=tuple(_list_prefixed_networks()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
|
||||||
for name in plan.containers:
|
|
||||||
info(f"container delete --force {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["container", "delete", "--force", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for name in plan.networks:
|
|
||||||
info(f"container network delete {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["container", "network", "delete", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Host-side egress apply for the macos-container backend.
|
|
||||||
|
|
||||||
Uses `container kill --signal HUP` (Apple Container framework) instead
|
|
||||||
of `docker kill` to signal the sidecar bundle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
|
||||||
from .launch import sidecar_container_name
|
|
||||||
|
|
||||||
|
|
||||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
|
||||||
container = sidecar_container_name(slug)
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = MacOSContainerEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Active-agent enumeration for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
|
||||||
if not name.startswith(_PREFIX):
|
|
||||||
continue
|
|
||||||
if name.startswith(_SIDECAR_PREFIX):
|
|
||||||
continue
|
|
||||||
slug = name[len(_PREFIX):]
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="macos-container",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "?",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
label=metadata.label if metadata else "",
|
|
||||||
color=metadata.color if metadata else "",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""MacosContainerFreezer — snapshot a macOS container bottle.
|
|
||||||
|
|
||||||
Apple Container removes containers when they stop, making stop-then-export
|
|
||||||
impossible. Instead, commit_container execs into the running container and
|
|
||||||
streams the root filesystem via tar. The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerFreezer(Freezer):
|
|
||||||
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
|
|
||||||
|
|
||||||
backend_name = "macos-container"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(
|
|
||||||
f"to export for migration: "
|
|
||||||
f"container image save {image_ref} -o {slug}.tar"
|
|
||||||
)
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
"""Launch flow for the macOS Apple Container backend.
|
|
||||||
|
|
||||||
This backend keeps the explicit proxy-env enforcement model for v1:
|
|
||||||
the agent container is attached only to a host-only Apple Container
|
|
||||||
network, while the sidecar bundle is attached to a NAT network first
|
|
||||||
and the host-only network second. The sidecar's host-only IP is
|
|
||||||
discovered from `container inspect` and stamped into the agent's
|
|
||||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
|
||||||
|
|
||||||
from ...bottle_state import (
|
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
|
||||||
from ...log import die, info, warn
|
|
||||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
|
||||||
from ..docker.git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ..docker.sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
)
|
|
||||||
from ..docker.egress import egress_tls_init
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle import MacosContainerBottle
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
|
||||||
|
|
||||||
|
|
||||||
def internal_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-net-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-egress-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
*,
|
|
||||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
|
||||||
stack = ExitStack()
|
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
|
||||||
|
|
||||||
def teardown() -> None:
|
|
||||||
teardown_exc: BaseException | None = None
|
|
||||||
try:
|
|
||||||
stack.close()
|
|
||||||
except BaseException as exc: # noqa: W0718 - teardown must continue
|
|
||||||
teardown_exc = exc
|
|
||||||
warn(f"macos-container teardown failed: {exc!r}")
|
|
||||||
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = _mint_certs(plan)
|
|
||||||
plan = _build_images(plan)
|
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
|
||||||
egress_network = egress_network_name(plan.slug)
|
|
||||||
_create_networks(internal_network, egress_network, stack)
|
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
|
||||||
container_mod.force_remove_container(sidecar_name)
|
|
||||||
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
stack.callback(container_mod.force_remove_container, sidecar_name)
|
|
||||||
_stage_git_gate(plan, sidecar_name)
|
|
||||||
|
|
||||||
sidecar_ip = container_mod.container_ipv4_on_network(
|
|
||||||
sidecar_name, internal_network,
|
|
||||||
)
|
|
||||||
plan = _stamp_agent_urls(plan, sidecar_ip)
|
|
||||||
|
|
||||||
container_mod.force_remove_container(plan.container_name)
|
|
||||||
_start_agent(plan, internal_network, sidecar_ip)
|
|
||||||
stack.callback(container_mod.force_remove_container, plan.container_name)
|
|
||||||
|
|
||||||
bottle = MacosContainerBottle(
|
|
||||||
plan.container_name,
|
|
||||||
teardown,
|
|
||||||
None,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
yield bottle
|
|
||||||
finally:
|
|
||||||
teardown()
|
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
egress_plan = dataclasses.replace(
|
|
||||||
plan.egress_plan,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
container_mod.build_image(
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and container_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(
|
|
||||||
plan.agent_provision,
|
|
||||||
image=committed,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
container_mod.build_image(
|
|
||||||
plan.image,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _create_networks(
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> None:
|
|
||||||
container_mod.create_network(internal_network, internal=True)
|
|
||||||
stack.callback(container_mod.remove_network, internal_network)
|
|
||||||
container_mod.create_network(egress_network)
|
|
||||||
stack.callback(container_mod.remove_network, egress_network)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_sidecar_bundle(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
env = {**os.environ, **token_values}
|
|
||||||
info(f"container run sidecar bundle {sidecar_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for sidecar bundle {sidecar_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_agent(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _agent_run_argv(plan, internal_network, sidecar_ip)
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
**plan.forwarded_env,
|
|
||||||
}
|
|
||||||
info(f"container run agent {plan.container_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for agent {plan.container_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp_agent_urls(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
supervise_url = ""
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
|
|
||||||
git_gate_url = ""
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_proxy_url=proxy_url,
|
|
||||||
agent_git_gate_url=git_gate_url,
|
|
||||||
agent_supervise_url=supervise_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if not gp.upstreams:
|
|
||||||
return
|
|
||||||
|
|
||||||
container_mod.exec_container(
|
|
||||||
sidecar_name,
|
|
||||||
[
|
|
||||||
"mkdir",
|
|
||||||
"-p",
|
|
||||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
"/git",
|
|
||||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
for host_path, container_path in _git_gate_files(plan):
|
|
||||||
container_mod.copy_into_container(
|
|
||||||
sidecar_name, host_path, container_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
container_mod.exec_container(
|
|
||||||
sidecar_name,
|
|
||||||
[
|
|
||||||
"sh",
|
|
||||||
"-c",
|
|
||||||
"chmod 755 "
|
|
||||||
f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
|
||||||
f"{GIT_GATE_HOOK_IN_CONTAINER} "
|
|
||||||
f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
|
|
||||||
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
|
|
||||||
f"touch {_GIT_GATE_READY_FILE}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_files(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str], ...]:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
files: list[tuple[str, str]] = [
|
|
||||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for upstream in gp.upstreams:
|
|
||||||
files.append((
|
|
||||||
expand_tilde(upstream.identity_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key",
|
|
||||||
))
|
|
||||||
if upstream.known_hosts_file:
|
|
||||||
files.append((
|
|
||||||
str(upstream.known_hosts_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts",
|
|
||||||
))
|
|
||||||
return tuple(files)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_run_argv(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", sidecar_name,
|
|
||||||
"--detach",
|
|
||||||
"--rm",
|
|
||||||
"--network", egress_network,
|
|
||||||
"--network", internal_network,
|
|
||||||
"--dns", _sidecar_dns(),
|
|
||||||
"--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}",
|
|
||||||
]
|
|
||||||
for entry in _sidecar_env_entries(plan):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
|
||||||
argv += ["--mount", _mount_spec(host_path, container_path, read_only)]
|
|
||||||
argv.append(SIDECAR_BUNDLE_IMAGE)
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_run_argv(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", plan.container_name,
|
|
||||||
"--detach",
|
|
||||||
"--network", internal_network,
|
|
||||||
]
|
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_dns() -> str:
|
|
||||||
return container_mod.dns_server()
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
daemons = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons += ["git-gate", "git-http"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
return tuple(daemons)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
env: list[str] = []
|
|
||||||
if plan.egress_plan.routes:
|
|
||||||
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_mounts(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str, bool], ...]:
|
|
||||||
mounts: list[tuple[str, str, bool]] = []
|
|
||||||
|
|
||||||
ep = plan.egress_plan
|
|
||||||
mounts.append((
|
|
||||||
str(ep.mitmproxy_ca_host_path.parent),
|
|
||||||
str(Path(EGRESS_CA_IN_CONTAINER).parent),
|
|
||||||
False,
|
|
||||||
))
|
|
||||||
if ep.routes:
|
|
||||||
mounts.append((
|
|
||||||
str(ep.routes_path.parent),
|
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
|
||||||
|
|
||||||
return tuple(mounts)
|
|
||||||
|
|
||||||
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
|
||||||
spec = f"type=bind,source={host_path},target={container_path}"
|
|
||||||
if read_only:
|
|
||||||
spec += ",readonly"
|
|
||||||
return spec
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_env_entries(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
no_proxy = _agent_no_proxy(plan, sidecar_ip)
|
|
||||||
env = [
|
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
|
||||||
f"HTTP_PROXY={proxy_url}",
|
|
||||||
f"https_proxy={proxy_url}",
|
|
||||||
f"http_proxy={proxy_url}",
|
|
||||||
f"NO_PROXY={no_proxy}",
|
|
||||||
f"no_proxy={no_proxy}",
|
|
||||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
||||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
||||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
||||||
]
|
|
||||||
if plan.agent_git_gate_url:
|
|
||||||
env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}")
|
|
||||||
if plan.agent_supervise_url:
|
|
||||||
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
|
|
||||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
|
||||||
env.append(f"{name}={value}")
|
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
|
||||||
env.append(name)
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
|
||||||
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
|
||||||
return ",".join(hosts)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Host-side raw-mode wrapper for `container exec --interactive --tty`.
|
|
||||||
|
|
||||||
Apple's `container exec --interactive --tty` does not set the host terminal to
|
|
||||||
raw mode before starting its I/O relay. Without raw mode the kernel line
|
|
||||||
discipline buffers modifier-key escape sequences (e.g. Shift+Enter in
|
|
||||||
modifyOtherKeys mode produces \\x1b[13;2~) until a carriage-return arrives, so
|
|
||||||
they never reach Claude Code inside the container.
|
|
||||||
|
|
||||||
This module sets the host terminal to raw mode, spawns the inner argv (the
|
|
||||||
container exec command), and restores the original terminal attributes on
|
|
||||||
exit. When stdin is not a TTY (piped invocations, CI) it falls through to a
|
|
||||||
bare subprocess.run so callers do not need to special-case non-interactive
|
|
||||||
contexts.
|
|
||||||
|
|
||||||
Usage (the `--` separator is the API contract — everything after it is the
|
|
||||||
inner command):
|
|
||||||
|
|
||||||
python pty_forward.py -- container exec --interactive --tty <name> <cmd>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
|
|
||||||
|
|
||||||
def _inner_env() -> dict[str, str]:
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.setdefault("TERM", "xterm-256color")
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _run_inner(inner: list[str]) -> int:
|
|
||||||
return subprocess.run(inner, check=False, env=_inner_env()).returncode
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Entry point. ``argv`` shape: ``-- <inner-argv...>``."""
|
|
||||||
if len(argv) < 2 or argv[0] != "--":
|
|
||||||
sys.stderr.write(
|
|
||||||
"usage: python pty_forward.py -- <container-exec-argv...>\n"
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
inner = argv[1:]
|
|
||||||
|
|
||||||
try:
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
except OSError:
|
|
||||||
return _run_inner(inner)
|
|
||||||
|
|
||||||
if not os.isatty(fd):
|
|
||||||
return _run_inner(inner)
|
|
||||||
|
|
||||||
try:
|
|
||||||
old = termios.tcgetattr(fd)
|
|
||||||
except termios.error:
|
|
||||||
return _run_inner(inner)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
return _run_inner(inner)
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Prepare step for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from .. import BottleSpec
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def preflight() -> None:
|
|
||||||
container_mod.require_container()
|
|
||||||
|
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return dict(resolved_env.literals)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
|
||||||
spec: BottleSpec,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
return MacosContainerBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
manifest=manifest,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug=slug,
|
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
agent_provision=agent_provision_plan,
|
|
||||||
)
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
"""Host-side primitives for Apple's `container` CLI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import ipaddress
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
_CONTAINER = "container"
|
|
||||||
_DEFAULT_DNS = "1.1.1.1"
|
|
||||||
|
|
||||||
|
|
||||||
def is_macos() -> bool:
|
|
||||||
return platform.system() == "Darwin"
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
|
||||||
return is_macos() and shutil.which(_CONTAINER) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def require_container() -> None:
|
|
||||||
"""Fail with an install pointer if Apple Container is unavailable."""
|
|
||||||
if not is_macos():
|
|
||||||
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
|
|
||||||
die("macos-container backend is only supported on macOS")
|
|
||||||
if shutil.which(_CONTAINER) is None:
|
|
||||||
info("Apple Container is required but was not found on PATH.")
|
|
||||||
info("Install: https://github.com/apple/container/releases")
|
|
||||||
die("container not found")
|
|
||||||
_require_container_service()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_container_service() -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "system", "status"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
info("Apple Container system service is not running.")
|
|
||||||
info("Start it with: container system start")
|
|
||||||
die("container system service not running")
|
|
||||||
|
|
||||||
|
|
||||||
def dns_server() -> str:
|
|
||||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
|
||||||
if override:
|
|
||||||
return override
|
|
||||||
return _host_ipv4_dns() or _DEFAULT_DNS
|
|
||||||
|
|
||||||
|
|
||||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
|
||||||
info(
|
|
||||||
f"building image {ref} from {context} with Apple Container "
|
|
||||||
"(layer cache keeps repeat builds fast)"
|
|
||||||
)
|
|
||||||
_ensure_builder_dns()
|
|
||||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
|
||||||
if dockerfile:
|
|
||||||
args.extend(["-f", dockerfile])
|
|
||||||
args.append(context)
|
|
||||||
subprocess.run(args, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
|
||||||
"""Snapshot a running Apple Container as a local image.
|
|
||||||
|
|
||||||
`container export` requires a stopped container, but Apple Container
|
|
||||||
removes containers when they stop, making stop-then-export impossible.
|
|
||||||
Instead, exec into the running container as root and stream the root
|
|
||||||
filesystem out via tar, then build a new image from that archive.
|
|
||||||
The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
|
||||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
|
||||||
dockerfile = os.path.join(tmp, "Dockerfile")
|
|
||||||
with open(rootfs_tar, "wb") as tar_out:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
_CONTAINER, "exec",
|
|
||||||
"--user", "root",
|
|
||||||
container_name,
|
|
||||||
"tar", "--create",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
"--file=-",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
stdout=tar_out,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container exec tar {container_name!r} failed: "
|
|
||||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
with open(dockerfile, "w", encoding="utf-8") as f:
|
|
||||||
f.write(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
build_image(image_tag, tmp, dockerfile=dockerfile)
|
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_builder_dns() -> None:
|
|
||||||
dns = dns_server()
|
|
||||||
status = _builder_status()
|
|
||||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
|
||||||
if _builder_running(status) and _builder_resolves_build_hosts():
|
|
||||||
if override and not _builder_has_dns(status, dns):
|
|
||||||
_restart_builder_with_dns(dns)
|
|
||||||
return
|
|
||||||
_restart_builder_with_dns(dns)
|
|
||||||
|
|
||||||
|
|
||||||
def _restart_builder_with_dns(dns: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
[_CONTAINER, "builder", "stop"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
[_CONTAINER, "builder", "start", "--dns", dns],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _host_ipv4_dns() -> str:
|
|
||||||
if not is_macos():
|
|
||||||
return ""
|
|
||||||
result = subprocess.run(
|
|
||||||
["scutil", "--dns"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return ""
|
|
||||||
blocks: list[list[str]] = []
|
|
||||||
current: list[str] = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if line.startswith("resolver #") and current:
|
|
||||||
blocks.append(current)
|
|
||||||
current = []
|
|
||||||
current.append(line)
|
|
||||||
if current:
|
|
||||||
blocks.append(current)
|
|
||||||
for direct_only in (True, False):
|
|
||||||
for block in blocks:
|
|
||||||
text = "\n".join(block)
|
|
||||||
if direct_only and "Directly Reachable Address" not in text:
|
|
||||||
continue
|
|
||||||
for line in block:
|
|
||||||
if "nameserver[" not in line or ":" not in line:
|
|
||||||
continue
|
|
||||||
candidate = line.split(":", 1)[1].strip()
|
|
||||||
if _usable_ipv4(candidate):
|
|
||||||
return candidate
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _usable_ipv4(value: str) -> bool:
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(value)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return (
|
|
||||||
address.version == 4
|
|
||||||
and not address.is_loopback
|
|
||||||
and not address.is_link_local
|
|
||||||
and not address.is_multicast
|
|
||||||
and not address.is_unspecified
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _builder_status() -> list[dict[str, object]]:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "builder", "status", "--format", "json"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return []
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [entry for entry in data if isinstance(entry, dict)]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return [data]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _builder_running(status: list[dict[str, object]]) -> bool:
|
|
||||||
for entry in status:
|
|
||||||
entry_status = entry.get("status")
|
|
||||||
if isinstance(entry_status, dict) and entry_status.get("state") == "running":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
for entry in status:
|
|
||||||
config = entry.get("configuration")
|
|
||||||
config_dns = config.get("dns") if isinstance(config, dict) else None
|
|
||||||
nameservers = (
|
|
||||||
config_dns.get("nameservers")
|
|
||||||
if isinstance(config_dns, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if not isinstance(nameservers, list):
|
|
||||||
continue
|
|
||||||
out.extend(name for name in nameservers if isinstance(name, str))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool:
|
|
||||||
return dns in _builder_dns_nameservers(status)
|
|
||||||
|
|
||||||
|
|
||||||
def _builder_resolves_build_hosts() -> bool:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def image_exists(ref: str) -> bool:
|
|
||||||
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def container_exists(name: str) -> bool:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "list", "--all", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
||||||
|
|
||||||
|
|
||||||
def container_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named container is currently running.
|
|
||||||
|
|
||||||
`container list` without `--all` lists only running containers."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_container(name: str) -> None:
|
|
||||||
"""Stop the named container without deleting it."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "stop", name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container stop {name!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
|
||||||
if container_exists(name):
|
|
||||||
subprocess.run(
|
|
||||||
[_CONTAINER, "delete", "--force", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def copy_into_container(name: str, host_path: str, container_path: str) -> None:
|
|
||||||
cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"]
|
|
||||||
result = _run_container_op(cmd)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container cp into {name}:{container_path} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def exec_container(name: str, argv: list[str]) -> None:
|
|
||||||
result = _run_container_op([_CONTAINER, "exec", name, *argv])
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container exec in {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for _ in range(19):
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result
|
|
||||||
time.sleep(0.1)
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def create_network(name: str, *, internal: bool = False) -> None:
|
|
||||||
args = [
|
|
||||||
_CONTAINER, "network", "create",
|
|
||||||
"--label", "bot-bottle.backend=macos-container",
|
|
||||||
]
|
|
||||||
if internal:
|
|
||||||
args.append("--internal")
|
|
||||||
args.append(name)
|
|
||||||
result = subprocess.run(
|
|
||||||
args, capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
if "already exists" in (result.stderr or "").lower():
|
|
||||||
return
|
|
||||||
die(
|
|
||||||
f"container network create {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_network(name: str) -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "network", "delete", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def inspect_container(name: str) -> dict[str, object]:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "inspect", name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container inspect {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
die(f"container inspect {name} returned malformed JSON: {exc}")
|
|
||||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
||||||
return data[0]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
die(f"container inspect {name} returned an unexpected shape")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def container_ipv4_on_network(name: str, network: str) -> str:
|
|
||||||
data = inspect_container(name)
|
|
||||||
status = data.get("status")
|
|
||||||
networks = status.get("networks") if isinstance(status, dict) else None
|
|
||||||
if not isinstance(networks, list):
|
|
||||||
die(f"container inspect {name} did not include status.networks")
|
|
||||||
for entry in networks:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
if entry.get("network") != network:
|
|
||||||
continue
|
|
||||||
raw = entry.get("ipv4Address")
|
|
||||||
if not isinstance(raw, str) or not raw:
|
|
||||||
die(f"container {name} has no IPv4 address on {network}")
|
|
||||||
return raw.split("/", 1)[0]
|
|
||||||
die(f"container {name} is not attached to network {network}")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
|
||||||
"""Return the image digest/ID from `container image inspect`.
|
|
||||||
|
|
||||||
The command returns JSON on current Apple Container releases. Keep
|
|
||||||
parsing narrow and fatal so callers do not cache on an empty key.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "image", "inspect", ref],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container image inspect for {ref!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "{}")
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
|
||||||
if isinstance(data, list) and data:
|
|
||||||
data = data[0]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
value = data.get("id") or data.get("digest") or data.get("ID")
|
|
||||||
if value:
|
|
||||||
return str(value)
|
|
||||||
die(f"container image inspect for {ref!r} did not include an image id")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def save(ref: str, output: str) -> None:
|
|
||||||
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _silent_run(cmd: Iterable[str]) -> int:
|
|
||||||
return subprocess.run(
|
|
||||||
list(cmd),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
).returncode
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Shared print helpers for BottlePlan.print implementations.
|
|
||||||
|
|
||||||
Lifts the multi-value label printer out of DockerBottlePlan so the
|
|
||||||
smolmachines backend (and any future backend) renders the same
|
|
||||||
two-column scannable preflight without duplicating the indent
|
|
||||||
math."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from ..log import info
|
|
||||||
|
|
||||||
|
|
||||||
def print_multi(label: str, values: Sequence[str]) -> None:
|
|
||||||
"""Print `label: <value>` with continuation lines indented to
|
|
||||||
align under the first value. Empty `values` renders `(none)`.
|
|
||||||
|
|
||||||
Used by every backend's `BottlePlan.print` for env / skills /
|
|
||||||
git / egress — one item per line keeps the preflight summary
|
|
||||||
scannable when an agent has many of any of these."""
|
|
||||||
if not values:
|
|
||||||
info(f"{label}: (none)")
|
|
||||||
return
|
|
||||||
info(f"{label}: {values[0]}")
|
|
||||||
indent = " " * (len(label) + 2)
|
|
||||||
for v in values[1:]:
|
|
||||||
info(f"{indent}{v}")
|
|
||||||
|
|
||||||
|
|
||||||
def visible_agent_env_names(
|
|
||||||
env_names: Sequence[str], *, hidden_env_names: frozenset[str],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Env names worth showing in launch summaries.
|
|
||||||
|
|
||||||
Provider-injected placeholder env vars are implementation details:
|
|
||||||
they are non-secret dummy values that satisfy provider CLIs while
|
|
||||||
egress injects the real Authorization header. The plan's
|
|
||||||
`hidden_env_names` carries exactly which names to suppress.
|
|
||||||
"""
|
|
||||||
return sorted({name for name in env_names if name and name not in hidden_env_names})
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Shared helpers used by both backends' resolve_plan steps.
|
|
||||||
|
|
||||||
Each helper owns one well-defined step of the per-bottle plan
|
|
||||||
resolution so docker and smolmachines don't repeat the same logic.
|
|
||||||
Backend-specific steps (container names, env-file, per-bottle
|
|
||||||
Dockerfile overrides, subnet allocation) stay in the backend's own
|
|
||||||
resolve_plan.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import replace
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan
|
|
||||||
from ..bottle_state import (
|
|
||||||
BottleMetadata,
|
|
||||||
agent_state_dir,
|
|
||||||
bottle_identity,
|
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
supervise_state_dir,
|
|
||||||
write_metadata,
|
|
||||||
)
|
|
||||||
from ..egress import Egress, EgressPlan
|
|
||||||
from ..git_gate import GitGate, GitGatePlan
|
|
||||||
from ..manifest import Manifest, ManifestBottle
|
|
||||||
from ..supervise import Supervise, SupervisePlan
|
|
||||||
from . import BottleSpec
|
|
||||||
|
|
||||||
|
|
||||||
def mint_slug(spec: BottleSpec) -> str:
|
|
||||||
"""Return the bottle identity: the recorded identity for a resume,
|
|
||||||
or a freshly minted one for a new start.
|
|
||||||
|
|
||||||
When a label is provided it becomes the full slug (no random suffix),
|
|
||||||
so two launches with the same label collide by design. When no label
|
|
||||||
is given the identity is minted with a random suffix to avoid
|
|
||||||
collisions between anonymous launches of the same agent."""
|
|
||||||
if spec.identity:
|
|
||||||
return spec.identity
|
|
||||||
if spec.label:
|
|
||||||
from .docker import util as docker_mod
|
|
||||||
return docker_mod.slugify(spec.label)
|
|
||||||
return bottle_identity(spec.agent_name)
|
|
||||||
|
|
||||||
|
|
||||||
def write_launch_metadata(
|
|
||||||
slug: str, spec: BottleSpec, *, compose_project: str, backend: str,
|
|
||||||
) -> None:
|
|
||||||
"""Persist launch metadata so `cli.py resume <identity>` can
|
|
||||||
reconstruct the spec. Idempotent — re-writes on resume with a
|
|
||||||
refreshed started_at."""
|
|
||||||
write_metadata(BottleMetadata(
|
|
||||||
identity=slug,
|
|
||||||
agent_name=spec.agent_name,
|
|
||||||
cwd=spec.user_cwd if spec.copy_cwd else "",
|
|
||||||
copy_cwd=spec.copy_cwd,
|
|
||||||
started_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
compose_project=compose_project,
|
|
||||||
backend=backend,
|
|
||||||
label=spec.label,
|
|
||||||
color=spec.color,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
|
||||||
"""Create the agent state subdir, write the prompt file.
|
|
||||||
Returns (agent_dir, prompt_file)."""
|
|
||||||
agent = manifest.agent
|
|
||||||
agent_dir = agent_state_dir(slug)
|
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
prompt_file = agent_dir / "prompt.txt"
|
|
||||||
prompt_file.write_text(agent.prompt or "")
|
|
||||||
prompt_file.chmod(0o600)
|
|
||||||
return agent_dir, prompt_file
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
|
|
||||||
git_gate_dir = git_gate_state_dir(slug)
|
|
||||||
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return GitGate().prepare(bottle, slug, git_gate_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_egress(
|
|
||||||
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
|
|
||||||
) -> EgressPlan:
|
|
||||||
egress_dir = egress_state_dir(slug)
|
|
||||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
|
||||||
"""Prepare the supervise sidecar state dir. Returns None when
|
|
||||||
bottle.supervise is falsy."""
|
|
||||||
if not bottle.supervise:
|
|
||||||
return None
|
|
||||||
supervise_dir = supervise_state_dir(slug)
|
|
||||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return Supervise().prepare(slug, supervise_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
|
|
||||||
"""Fold provision.env_vars into guest_env (setdefault semantics)
|
|
||||||
and return a new plan with the merged guest_env."""
|
|
||||||
merged = dict(provision.guest_env)
|
|
||||||
for key, val in provision.env_vars.items():
|
|
||||||
merged.setdefault(key, val)
|
|
||||||
return replace(provision, guest_env=merged)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
|
||||||
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
|
|
||||||
path = Path(os.path.expanduser(path_value))
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = Path(spec.user_cwd) / path
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"merge_provision_env_vars",
|
|
||||||
"mint_slug",
|
|
||||||
"prepare_agent_state_dir",
|
|
||||||
"prepare_egress",
|
|
||||||
"prepare_git_gate",
|
|
||||||
"prepare_supervise",
|
|
||||||
"resolve_manifest_dockerfile",
|
|
||||||
"write_launch_metadata",
|
|
||||||
]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""smolmachines bottle backend (PRD 0023).
|
|
||||||
|
|
||||||
Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each
|
|
||||||
bottle inside a per-agent microVM (libkrun / Hypervisor.framework
|
|
||||||
on macOS) with a userspace gvproxy gateway as the egress
|
|
||||||
primitive. The sidecar bundle (PRD 0024) runs as a host-side
|
|
||||||
docker container reached only through gvproxy's port-forward list.
|
|
||||||
|
|
||||||
Chunk 1 (this commit) ships the backend skeleton + Smolfile +
|
|
||||||
gvproxy renderers + preflight check. VM lifecycle, sidecar
|
|
||||||
bringup, and provisioning land in later chunks."""
|
|
||||||
|
|
||||||
from .backend import SmolmachinesBottleBackend # noqa: F401
|
|
||||||
|
|
||||||
__all__ = ["SmolmachinesBottleBackend"]
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
|
||||||
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 contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Generator, Sequence
|
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
|
||||||
from . import cleanup as _cleanup
|
|
||||||
from . import enumerate as _enumerate
|
|
||||||
from . import launch as _launch
|
|
||||||
from . import resolve_plan as _resolve_plan
|
|
||||||
from . import smolvm as _smolvm
|
|
||||||
from .bottle import SmolmachinesBottle
|
|
||||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesBottleBackend(
|
|
||||||
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
|
||||||
):
|
|
||||||
"""smolmachines backend. Selected by
|
|
||||||
`BOT_BOTTLE_BACKEND=smolmachines`."""
|
|
||||||
|
|
||||||
name = "smolmachines"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
"""`smolvm` on PATH. The backend additionally needs macOS
|
|
||||||
for libkrun + TSI, but `enumerate_active` / `cleanup` are
|
|
||||||
host-shell ops that gracefully no-op on Linux too — the
|
|
||||||
runtime check happens at `prepare`."""
|
|
||||||
return _smolvm.is_available()
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
_resolve_plan.preflight()
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return _resolve_plan.build_guest_env(resolved_env)
|
|
||||||
|
|
||||||
def _resolve_plan(
|
|
||||||
self,
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> SmolmachinesBottlePlan:
|
|
||||||
return _resolve_plan.resolve_plan(
|
|
||||||
spec,
|
|
||||||
manifest=manifest,
|
|
||||||
slug=slug,
|
|
||||||
resolved_env=resolved_env,
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
self, plan: SmolmachinesBottlePlan
|
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
|
||||||
yield bottle
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
|
||||||
"""The smolmachines guest reaches the supervise sidecar via a
|
|
||||||
host-published random port the launch step pinned earlier
|
|
||||||
(`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:
|
|
||||||
return _cleanup.prepare_cleanup()
|
|
||||||
|
|
||||||
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
|
|
||||||
_cleanup.cleanup(plan)
|
|
||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
return _enumerate.enumerate_active()
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
|
||||||
|
|
||||||
Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine
|
|
||||||
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
|
||||||
and torn down via the surrounding ExitStack on context exit;
|
|
||||||
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
|
||||||
context-manager contract is satisfied.
|
|
||||||
|
|
||||||
User context: `smolvm machine exec` runs commands as root in the
|
|
||||||
VM, but the agent image's USER is `node` and agent CLIs may refuse
|
|
||||||
to run as root in bypass modes. Both
|
|
||||||
`exec_agent` and `exec` switch to the requested user (default
|
|
||||||
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
|
||||||
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
|
||||||
(PAM session setup, /etc/profile sourcing) which can hang on a
|
|
||||||
minimal Debian VM with no PAM session config."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import shlex
|
|
||||||
from typing import Mapping, cast
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
|
||||||
from .. import Bottle, ExecResult
|
|
||||||
from ..terminal import exec_shell_script
|
|
||||||
from . import pty_resize as _pty_resize
|
|
||||||
from . import smolvm as _smolvm
|
|
||||||
|
|
||||||
|
|
||||||
# Absolute path to the pty_resize wrapper. Invoke as
|
|
||||||
# `python <path>` rather than `python -m <dotted-path>` so the
|
|
||||||
# wrapper runs regardless of cwd / sys.path — it has no
|
|
||||||
# bot_bottle.* imports, so it's self-contained.
|
|
||||||
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
|
|
||||||
|
|
||||||
|
|
||||||
# Per-user env the agent image's USER (node) expects. Some providers
|
|
||||||
# write session state under the user's home directory;
|
|
||||||
# bare `runuser -u` inherits root's HOME=/root, which claude
|
|
||||||
# can't write to. Set HOME / USER explicitly through smolvm -e
|
|
||||||
# so the child process sees them.
|
|
||||||
_HOME_FOR = {
|
|
||||||
"node": "/home/node",
|
|
||||||
"root": "/root",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]:
|
|
||||||
home = _HOME_FOR.get(user, f"/home/{user}")
|
|
||||||
out = [f"HOME={home}", f"USER={user}"]
|
|
||||||
for k, v in env.items():
|
|
||||||
out.append(f"{k}={v}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesBottle(Bottle):
|
|
||||||
"""Handle returned by `SmolmachinesBottleBackend.launch`. The
|
|
||||||
underlying VM lifecycle (create / start / stop / delete) lives
|
|
||||||
on the launch ExitStack — this class only routes runtime
|
|
||||||
operations to the right `smolvm machine ...` subcommand."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
machine_name: str,
|
|
||||||
*,
|
|
||||||
prompt_path: str | None = None,
|
|
||||||
guest_env: Mapping[str, str] | None = None,
|
|
||||||
agent_command: str = "claude",
|
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
terminal_title: str = "",
|
|
||||||
terminal_color: str = "",
|
|
||||||
agent_workdir: str = "/home/node",
|
|
||||||
) -> None:
|
|
||||||
self.name = machine_name
|
|
||||||
# In-VM path to the agent's prompt file. None when the
|
|
||||||
# agent declared no prompt (file still exists; we just
|
|
||||||
# don't pass --append-system-prompt-file).
|
|
||||||
self.prompt_path = prompt_path
|
|
||||||
# Env vars the agent process needs (HTTPS_PROXY,
|
|
||||||
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
|
|
||||||
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
|
||||||
# because exec doesn't inherit from machine_create's env.
|
|
||||||
self._guest_env = dict(guest_env or {})
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
|
||||||
self.agent_command = agent_command
|
|
||||||
self.terminal_title = terminal_title
|
|
||||||
self.terminal_color = terminal_color
|
|
||||||
self.agent_provider_template = agent_provider_template
|
|
||||||
self.agent_workdir = agent_workdir
|
|
||||||
|
|
||||||
def agent_argv(
|
|
||||||
self, argv: list[str], *, tty: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
|
||||||
if tty:
|
|
||||||
flags += ["-i", "-t"]
|
|
||||||
agent_tail = ["env", *_env_assignments_for("node", self._guest_env)]
|
|
||||||
if self.agent_workdir and self.agent_workdir != _HOME_FOR["node"]:
|
|
||||||
agent_tail += [
|
|
||||||
"sh", "-lc",
|
|
||||||
f"cd {shlex.quote(self.agent_workdir)} && exec \"$@\"",
|
|
||||||
"bot-bottle-agent",
|
|
||||||
]
|
|
||||||
agent_tail.append(self.agent_command)
|
|
||||||
provider_prompt_args = prompt_args(
|
|
||||||
cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv,
|
|
||||||
)
|
|
||||||
if cast(PromptMode, self._agent_prompt_mode) == "read_prompt_file":
|
|
||||||
agent_tail += argv
|
|
||||||
agent_tail += provider_prompt_args
|
|
||||||
else:
|
|
||||||
agent_tail += provider_prompt_args
|
|
||||||
agent_tail += argv
|
|
||||||
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
|
|
||||||
if not tty:
|
|
||||||
# No PTY allocated — no SIGWINCH to forward, no resize
|
|
||||||
# bridge needed. Skip the wrapper so non-interactive
|
|
||||||
# exec paths (e.g., provisioning shell-outs that
|
|
||||||
# happen to go through this method) stay light.
|
|
||||||
return flags
|
|
||||||
return [
|
|
||||||
sys.executable, _PTY_RESIZE_SCRIPT,
|
|
||||||
self.name, "--", *flags,
|
|
||||||
]
|
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
|
||||||
"""Run the selected agent interactively inside the VM as the `node`
|
|
||||||
user. Inherits the operator's terminal (stdin / stdout /
|
|
||||||
stderr) so the session feels native. Blocks until the agent
|
|
||||||
exits; returns the in-VM exit code.
|
|
||||||
|
|
||||||
We bypass the captured-output `machine_exec` helper here
|
|
||||||
because that one wraps stdout/stderr in pipes — fine for
|
|
||||||
scripted exec, wrong for an interactive shell. Drop down
|
|
||||||
to `subprocess.run` with the TTY inherited.
|
|
||||||
|
|
||||||
UID switches via `runuser -u node --` (not `-l`) so we
|
|
||||||
avoid login-shell wiring. HOME / USER come from `smolvm
|
|
||||||
-e` instead, which sets them on the process env."""
|
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
|
||||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
|
||||||
if script is None:
|
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
|
||||||
# Use sh -c (not -lc) so the script inherits PATH from the calling
|
|
||||||
# process. sh -l sources login-shell init files (e.g. /etc/profile)
|
|
||||||
# which may NOT include smolvm's location when it was installed via
|
|
||||||
# homebrew. The calling process (./cli.py) already has smolvm on PATH
|
|
||||||
# (provision steps succeed), so -c is sufficient.
|
|
||||||
return subprocess.run(["sh", "-c", script], check=False).returncode
|
|
||||||
|
|
||||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
|
||||||
# early-VM provisioning. Retry once after a short settle so
|
|
||||||
# callers (provision_ca, etc.) don't have to handle it themselves.
|
|
||||||
_SIGKILL_EXIT = 128 + 9
|
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
"""Run a POSIX shell script as `user` (default `node`) and
|
|
||||||
capture the result. Matches the docker backend's `exec`,
|
|
||||||
which defaults to the image's USER (also node) — so test
|
|
||||||
helpers / provision shell-outs run with the same identity
|
|
||||||
on both backends. Pass `user="root"` for tests that need
|
|
||||||
root.
|
|
||||||
|
|
||||||
`runuser -u <user> -- env ... /bin/sh -c <script>` switches UID
|
|
||||||
without invoking a login shell, then sets HOME / USER and the
|
|
||||||
bottle env in the child process.
|
|
||||||
|
|
||||||
Retries once on SIGKILL (exit 137) — libkrun occasionally
|
|
||||||
kills short-lived execs during VM bring-up."""
|
|
||||||
r = self._exec_raw(script, user=user)
|
|
||||||
if r.returncode == self._SIGKILL_EXIT:
|
|
||||||
time.sleep(1.0)
|
|
||||||
r = self._exec_raw(script, user=user)
|
|
||||||
return r
|
|
||||||
|
|
||||||
def _exec_raw(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
argv = [
|
|
||||||
"--", "runuser", "-u", user, "--",
|
|
||||||
"env", *_env_assignments_for(user, self._guest_env),
|
|
||||||
"/bin/sh", "-c", script,
|
|
||||||
]
|
|
||||||
r = subprocess.run(
|
|
||||||
["smolvm", "machine", "exec", "--name", self.name] + argv,
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
return ExecResult(
|
|
||||||
returncode=r.returncode,
|
|
||||||
stdout=r.stdout or "",
|
|
||||||
stderr=r.stderr or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
|
||||||
"""Copy a host path into the guest at `container_path`."""
|
|
||||||
_smolvm.machine_cp(host_path, f"{self.name}:{container_path}")
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
# Real teardown lives on the launch ExitStack; this is just
|
|
||||||
# the idempotent alias the BottleBackend ABC expects.
|
|
||||||
pass
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""SmolmachinesBottleCleanupPlan — concrete BottleCleanupPlan (issue #77).
|
|
||||||
|
|
||||||
Tracks the resources `SmolmachinesBottleBackend.cleanup` will
|
|
||||||
remove:
|
|
||||||
|
|
||||||
- machines: smolvm machines whose name starts with
|
|
||||||
`bot-bottle-` (running or stopped). Stopped +
|
|
||||||
deleted via `smolvm machine stop` + `machine delete -f`.
|
|
||||||
- bundles: docker containers `bot-bottle-sidecars-<slug>`
|
|
||||||
left over from a smolmachines bottle (the bundle's
|
|
||||||
port-forwards stay published on lo0 aliases until
|
|
||||||
the container is gone). Removed via `docker rm -f`.
|
|
||||||
- networks: docker networks `bot-bottle-bundle-<slug>`
|
|
||||||
attached to the bundles. Removed via
|
|
||||||
`docker network rm`.
|
|
||||||
|
|
||||||
Smolmachines state dirs live under the same `~/.bot-bottle/state/`
|
|
||||||
path the docker backend uses; the docker backend's
|
|
||||||
`prepare_cleanup` already enumerates orphan state dirs and is the
|
|
||||||
single source of truth for that bucket (consults
|
|
||||||
`enumerate_active_bottles()` so it doesn't reap a live
|
|
||||||
smolmachines bottle's dir)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from ...log import info
|
|
||||||
from .. import BottleCleanupPlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SmolmachinesBottleCleanupPlan(BottleCleanupPlan):
|
|
||||||
"""Resources SmolmachinesBottleBackend.cleanup will remove.
|
|
||||||
Produced by `prepare_cleanup`; sorted so the y/N output is
|
|
||||||
stable."""
|
|
||||||
|
|
||||||
machines: tuple[str, ...] = ()
|
|
||||||
bundles: tuple[str, ...] = ()
|
|
||||||
networks: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
return not self.machines and not self.bundles and not self.networks
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
print(file=sys.stderr)
|
|
||||||
for name in self.machines:
|
|
||||||
info(f"smolvm machine: {name}")
|
|
||||||
for name in self.bundles:
|
|
||||||
info(f"bundle container:{name}")
|
|
||||||
for name in self.networks:
|
|
||||||
info(f"bundle network: {name}")
|
|
||||||
print(file=sys.stderr)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""SmolmachinesBottlePlan — concrete BottlePlan for the smolmachines
|
|
||||||
backend (PRD 0023).
|
|
||||||
|
|
||||||
Slug + bundle docker subnet / gateway / pinned IP + smolvm
|
|
||||||
machine name + agent `.smolmachine` artifact + per-bottle guest
|
|
||||||
env. Provisioning fields (CA cert path, prompt path, etc.) land
|
|
||||||
in chunk 4."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode
|
|
||||||
from .. import BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SmolmachinesBottlePlan(BottlePlan):
|
|
||||||
"""Resolved fields the launch step needs to bring up the bottle.
|
|
||||||
|
|
||||||
Inherits `spec`, `stage_dir`, `git_gate_plan`, `egress_plan`,
|
|
||||||
`supervise_plan`, and `agent_provision` from BottlePlan."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
# Per-bottle docker subnet for the sidecar bundle container.
|
|
||||||
# The bundle runs at `bundle_ip` (always `.2`); the gateway is
|
|
||||||
# at `.1`. smolvm's TSI allowlist is set to `bundle_ip/32`.
|
|
||||||
bundle_subnet: str
|
|
||||||
bundle_gateway: str
|
|
||||||
bundle_ip: str
|
|
||||||
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
|
|
||||||
# the guest has no DNS resolver inside the TSI allowlist.
|
|
||||||
# Passed to `smolvm machine create` as `-e K=V` flags.
|
|
||||||
# Smolfile-rendering is gone (smolvm 0.8.0's
|
|
||||||
# `--smolfile` is mutually exclusive with `--from`, and
|
|
||||||
# `--from` is the path that avoids the registry-pull race).
|
|
||||||
guest_env: dict[str, str]
|
|
||||||
# Inner Plans for the sidecar bundle daemons. The same shape the
|
|
||||||
# docker backend uses — same `.prepare()` calls produced
|
|
||||||
# them — but our launch step doesn't populate the
|
|
||||||
# docker-specific network fields (internal_network,
|
|
||||||
# egress_network) because the smolmachines bundle isn't on
|
|
||||||
# docker's `--internal` + egress bridge topology; it's on a
|
|
||||||
# per-bottle bridge with a pinned IP. The unused fields stay
|
|
||||||
# at their dataclass defaults.
|
|
||||||
# Agent-side endpoints. On Docker Desktop the docker bridge
|
|
||||||
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
|
||||||
# networking; docker container IPs live in the daemon's VM),
|
|
||||||
# so the agent dials the bundle via host loopback +
|
|
||||||
# docker-published random ports. Empty at prepare time;
|
|
||||||
# launch populates these after bundle bringup via
|
|
||||||
# `dataclasses.replace`. Format: a `host:port` for git-gate
|
|
||||||
# (insteadOf URL prefix) + full URLs for proxy / supervise.
|
|
||||||
agent_proxy_url: str = ""
|
|
||||||
agent_git_gate_host: str = ""
|
|
||||||
agent_supervise_url: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def machine_name(self) -> str:
|
|
||||||
"""smolvm machine name. `machine_create` boots from a packed
|
|
||||||
`.smolmachine` artifact (pre-baked at prepare time via
|
|
||||||
`smolvm pack create`); using `--from` instead of `--image`
|
|
||||||
avoids the registry-pull race we hit when machine_start tried
|
|
||||||
to fetch on-demand and the libkrun agent's network attempt
|
|
||||||
got refused by macOS."""
|
|
||||||
return self.agent_provision.instance_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_image(self) -> str:
|
|
||||||
"""Agent image ref (docker tag). `launch` runs the
|
|
||||||
build → save → registry push → smolvm pack pipeline against
|
|
||||||
this and feeds the resulting `.smolmachine` artifact to
|
|
||||||
`machine_create --from`. The pipeline runs at launch time
|
|
||||||
(not prepare time) so the docker build output doesn't garble
|
|
||||||
the dashboard's preflight modal."""
|
|
||||||
return self.agent_provision.image
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_file(self) -> Path:
|
|
||||||
"""Path to the agent's prompt file on the host. Always written
|
|
||||||
(mode 0o600) so the in-VM path always exists; the file is
|
|
||||||
empty when the agent has no prompt — claude-code reads it
|
|
||||||
via --append-system-prompt-file only when non-empty."""
|
|
||||||
return self.agent_provision.prompt_file
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_host(self) -> str:
|
|
||||||
return self.agent_git_gate_host
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_scheme(self) -> str:
|
|
||||||
return "http"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_command(self) -> str:
|
|
||||||
return self.agent_provision.command
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_prompt_mode(self) -> PromptMode:
|
|
||||||
return self.agent_provision.prompt_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_provider_template(self) -> str:
|
|
||||||
return self.agent_provision.template
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_dockerfile_path(self) -> str:
|
|
||||||
return self.agent_provision.dockerfile
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""Cleanup + active-listing for the smolmachines backend (issue #77).
|
|
||||||
|
|
||||||
`prepare_cleanup` enumerates leftover smolmachines resources:
|
|
||||||
|
|
||||||
- smolvm machines (`smolvm machine ls --json`) whose name starts
|
|
||||||
with `bot-bottle-`.
|
|
||||||
- bundle docker containers (`bot-bottle-sidecars-<slug>`).
|
|
||||||
- bundle docker networks (`bot-bottle-bundle-<slug>`).
|
|
||||||
|
|
||||||
State dirs live under `~/.bot-bottle/state/<identity>/` —
|
|
||||||
shared layout with the docker backend, which has the single
|
|
||||||
orphan-state-dir enumerator (it already consults
|
|
||||||
`enumerate_active_agents()` so a live smolmachines bottle's dir
|
|
||||||
is preserved).
|
|
||||||
|
|
||||||
`cleanup` removes everything in the plan: stop + delete each VM,
|
|
||||||
force-rm each container, rm each network. Each step is
|
|
||||||
best-effort — a failure on one resource doesn't block the others."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import info, warn
|
|
||||||
from . import sidecar_bundle as _bundle
|
|
||||||
from . import smolvm as _smolvm
|
|
||||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
|
||||||
|
|
||||||
|
|
||||||
# Both names start with the same prefix the launcher uses.
|
|
||||||
_VM_PREFIX = "bot-bottle-"
|
|
||||||
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `bot-bottle-sidecars-`
|
|
||||||
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `bot-bottle-bundle-`
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
|
|
||||||
"""Enumerate every smolmachines-owned resource on the host.
|
|
||||||
No side effects. Returns an empty plan when smolvm isn't on
|
|
||||||
PATH (no machines to reap) — `cleanup` is a no-op in that
|
|
||||||
case too."""
|
|
||||||
machines = _list_bot_bottle_machines()
|
|
||||||
bundles = _list_bundle_containers()
|
|
||||||
networks = _list_bundle_networks()
|
|
||||||
return SmolmachinesBottleCleanupPlan(
|
|
||||||
machines=tuple(sorted(machines)),
|
|
||||||
bundles=tuple(sorted(bundles)),
|
|
||||||
networks=tuple(sorted(networks)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
|
|
||||||
"""Remove everything in the plan. Order matters: stop VMs
|
|
||||||
first (they hold ports on lo0 aliases via libkrun), then the
|
|
||||||
bundle containers (which hold the host port-forwards), then
|
|
||||||
the networks (which docker won't reap until the containers
|
|
||||||
are gone)."""
|
|
||||||
for name in plan.machines:
|
|
||||||
info(f"stopping smolvm machine {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["smolvm", "machine", "stop", "--name", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
info(f"deleting smolvm machine {name}")
|
|
||||||
r = subprocess.run(
|
|
||||||
["smolvm", "machine", "delete", "-f", name],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"smolvm machine delete -f {name} failed: "
|
|
||||||
f"{(r.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in plan.bundles:
|
|
||||||
info(f"removing bundle container {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in plan.networks:
|
|
||||||
info(f"removing bundle network {name}")
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "network", "rm", name],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0 and "no such network" not in (r.stderr or "").lower():
|
|
||||||
warn(
|
|
||||||
f"docker network rm {name} failed: "
|
|
||||||
f"{(r.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _list_bot_bottle_machines() -> list[str]:
|
|
||||||
"""All smolvm machines named `bot-bottle-*`, regardless of
|
|
||||||
state (running / stopped / created). Empty when smolvm isn't
|
|
||||||
installed."""
|
|
||||||
if not _smolvm.is_available():
|
|
||||||
return []
|
|
||||||
r = subprocess.run(
|
|
||||||
["smolvm", "machine", "ls", "--json"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
machines = json.loads(r.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
m["name"] for m in machines
|
|
||||||
if isinstance(m, dict)
|
|
||||||
and m.get("name", "").startswith(_VM_PREFIX)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _list_bundle_containers() -> list[str]:
|
|
||||||
"""All docker containers named `bot-bottle-sidecars-*`,
|
|
||||||
running or stopped. Empty when docker isn't installed."""
|
|
||||||
# Late import: `backend/__init__` imports this module
|
|
||||||
# transitively via the smolmachines backend.
|
|
||||||
from .. import has_backend
|
|
||||||
if not has_backend("docker"):
|
|
||||||
return []
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "ps", "-a",
|
|
||||||
"--filter", f"name=^{_BUNDLE_PREFIX}",
|
|
||||||
"--format", "{{.Names}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
line for line in (r.stdout or "").splitlines()
|
|
||||||
if line and line.startswith(_BUNDLE_PREFIX)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _list_bundle_networks() -> list[str]:
|
|
||||||
"""All docker networks named `bot-bottle-bundle-*`. Empty
|
|
||||||
when docker isn't installed."""
|
|
||||||
from .. import has_backend
|
|
||||||
if not has_backend("docker"):
|
|
||||||
return []
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "network", "ls",
|
|
||||||
"--filter", f"name={_NETWORK_PREFIX}",
|
|
||||||
"--format", "{{.Name}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
line for line in (r.stdout or "").splitlines()
|
|
||||||
if line and line.startswith(_NETWORK_PREFIX)
|
|
||||||
]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Egress apply for the smolmachines backend.
|
|
||||||
|
|
||||||
The smolmachines sidecar bundle runs as a host-side Docker container,
|
|
||||||
so egress signalling is identical to the docker backend.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ..docker.egress_apply import ( # noqa: F401
|
|
||||||
DockerEgressApplicator,
|
|
||||||
EgressApplyError,
|
|
||||||
applicator,
|
|
||||||
fetch_current_routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
|
||||||
]
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Active-agent enumeration for the smolmachines backend (PRD
|
|
||||||
0023 chunk 4 follow-up + issue #77).
|
|
||||||
|
|
||||||
Returns a list of `ActiveAgent` records — same shape the docker
|
|
||||||
backend produces — so CLI `list active` and the dashboard agents
|
|
||||||
pane render both backends through one code path.
|
|
||||||
|
|
||||||
A smolmachines agent is "active" when its smolvm guest is
|
|
||||||
running. We cross-reference against the per-bottle sidecar
|
|
||||||
bundle container to populate the `services` field (which daemons
|
|
||||||
are up in the bundle); without a bundle we still surface the VM
|
|
||||||
so the operator can see + clean it up.
|
|
||||||
|
|
||||||
The cross-backend caller gates on `has_backend("smolmachines")`
|
|
||||||
and `has_backend("docker")`, so this module assumes both are
|
|
||||||
available when called. Both subprocess calls below still
|
|
||||||
tolerate "command not on PATH" defensively, but the gate is the
|
|
||||||
intended access pattern."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from . import sidecar_bundle as _bundle
|
|
||||||
|
|
||||||
|
|
||||||
# Smolvm VM names produced by prepare are `bot-bottle-<slug>`,
|
|
||||||
# matching the bundle container name pattern. We use the prefix
|
|
||||||
# both as a filter and to strip back to the slug.
|
|
||||||
_VM_NAME_PREFIX = "bot-bottle-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
|
||||||
"""All currently-running smolmachines-backed agents. Empty
|
|
||||||
list when no matching VMs are running. Caller is responsible
|
|
||||||
for gating on `has_backend('smolmachines')` if needed; if
|
|
||||||
smolvm is missing the `smolvm machine ls` call below returns
|
|
||||||
nothing silently."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["smolvm", "machine", "ls", "--json"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
machines = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return []
|
|
||||||
services_by_slug = _query_bundle_services()
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for m in machines:
|
|
||||||
name = m.get("name") or ""
|
|
||||||
state = m.get("state") or ""
|
|
||||||
if state != "running" or not name.startswith(_VM_NAME_PREFIX):
|
|
||||||
continue
|
|
||||||
slug = name[len(_VM_NAME_PREFIX):]
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="smolmachines",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "?",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=services_by_slug.get(slug, ()),
|
|
||||||
label=metadata.label if metadata else "",
|
|
||||||
color=metadata.color if metadata else "",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
|
||||||
"""`{slug: ('egress', ...)}` from each running bundle container's
|
|
||||||
`BOT_BOTTLE_SIDECAR_DAEMONS` env var.
|
|
||||||
Smolmachines bundles all run the PRD-0024 image with the
|
|
||||||
same daemon set declared via env, so one inspect per bundle
|
|
||||||
gets us the picture without exec'ing into the container.
|
|
||||||
|
|
||||||
Returns an empty mapping when the docker backend isn't
|
|
||||||
available — the bundle services field on each ActiveAgent
|
|
||||||
just shows up empty, matching the docker backend's "starting"
|
|
||||||
state."""
|
|
||||||
# Late import: `has_backend` lives on the backend package's
|
|
||||||
# __init__, which imports this module transitively. Pulling
|
|
||||||
# the name in at call time sidesteps the cycle.
|
|
||||||
from .. import has_backend
|
|
||||||
if not has_backend("docker"):
|
|
||||||
return {}
|
|
||||||
ps = subprocess.run(
|
|
||||||
["docker", "ps",
|
|
||||||
"--filter", "name=" + _bundle.bundle_container_name(""),
|
|
||||||
"--format", "{{.Names}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if ps.returncode != 0:
|
|
||||||
return {}
|
|
||||||
out: dict[str, tuple[str, ...]] = {}
|
|
||||||
for line in (ps.stdout or "").splitlines():
|
|
||||||
name = line.strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
slug = name.removeprefix(_bundle.bundle_container_name(""))
|
|
||||||
if not slug:
|
|
||||||
continue
|
|
||||||
inspect = subprocess.run(
|
|
||||||
["docker", "inspect", name, "--format", "{{json .Config.Env}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if inspect.returncode != 0:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
env_list = json.loads(inspect.stdout or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
for entry in env_list:
|
|
||||||
key, _, value = entry.partition("=")
|
|
||||||
if key == "BOT_BOTTLE_SIDECAR_DAEMONS":
|
|
||||||
out[slug] = tuple(sorted(
|
|
||||||
d for d in value.split(",") if d
|
|
||||||
))
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
|
|
||||||
|
|
||||||
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
|
|
||||||
removes VMs when stopped (same issue as Apple Container). Instead, exec
|
|
||||||
into the running VM as root to write a gzip-compressed tar of the root
|
|
||||||
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
|
|
||||||
build a Docker image from the archive, convert it to a smolmachine artifact
|
|
||||||
via the existing registry pipeline, and record the sidecar path. The VM
|
|
||||||
stays running throughout."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from ..docker import util as docker_mod
|
|
||||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
|
||||||
from .smolvm import machine_cp, machine_exec, pack_create
|
|
||||||
from ...bottle_state import bottle_state_dir
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
# Temp file written inside the VM during commit. Lives in /var/tmp
|
|
||||||
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
|
|
||||||
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesFreezer(Freezer):
|
|
||||||
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
|
|
||||||
|
|
||||||
The VM is NOT stopped. We exec into the running VM to write a compressed
|
|
||||||
tar of the root filesystem to /var/tmp, copy it to the host with
|
|
||||||
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
|
|
||||||
automatically), then run the same image→registry→pack_create pipeline
|
|
||||||
that _ensure_smolmachine uses for fresh builds."""
|
|
||||||
|
|
||||||
backend_name = "smolmachines"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
machine = f"bot-bottle-{agent.slug}"
|
|
||||||
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
output_dir = bottle_state_dir(agent.slug)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
binary = output_dir / "committed-smolmachine"
|
|
||||||
sidecar = output_dir / "committed-smolmachine.smolmachine"
|
|
||||||
_snapshot_running_vm(machine, image_ref, binary)
|
|
||||||
return str(sidecar)
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
|
|
||||||
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
|
|
||||||
|
|
||||||
binary: destination for the launcher (sibling .smolmachine is the artifact
|
|
||||||
that machine_create --from consumes, same convention as pack_create).
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
|
|
||||||
tmp_path = Path(tmp)
|
|
||||||
# Use .tar.gz — Docker ADD decompresses automatically and the
|
|
||||||
# compressed archive fits in the VM's /var/tmp more easily.
|
|
||||||
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
|
|
||||||
dockerfile = tmp_path / "Dockerfile"
|
|
||||||
|
|
||||||
_exec_tar_to_file(machine, rootfs_tar_gz)
|
|
||||||
|
|
||||||
dockerfile.write_text(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar.gz /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
|
|
||||||
|
|
||||||
image_tarball = binary.parent / "committed.image.tar"
|
|
||||||
docker_mod.save(image_ref, str(image_tarball))
|
|
||||||
try:
|
|
||||||
with ephemeral_registry() as handle:
|
|
||||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
|
||||||
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
crane_push_tarball(handle, str(image_tarball), push_ref)
|
|
||||||
pack_create(pack_ref, binary)
|
|
||||||
finally:
|
|
||||||
image_tarball.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_tar_to_file(machine: str, dest: Path) -> None:
|
|
||||||
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
|
|
||||||
|
|
||||||
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
|
|
||||||
machine_exec (same mechanism as provisioning), then copies it to the
|
|
||||||
host with machine_cp. This avoids binary-stdout piping through the
|
|
||||||
smolvm exec channel, which does not reliably handle large binary output.
|
|
||||||
|
|
||||||
A connectivity probe (machine_exec true) runs first so a concurrent-exec
|
|
||||||
limitation (smolvm may reject a second exec while -i -t is active) is
|
|
||||||
reported clearly rather than as a silent failure."""
|
|
||||||
# Connectivity probe — if smolvm rejects concurrent exec while an
|
|
||||||
# interactive session is running, fail clearly here.
|
|
||||||
probe = machine_exec(machine, ["true"])
|
|
||||||
if probe.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"smolvm exec is not available for {machine!r} "
|
|
||||||
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
|
|
||||||
f"If an interactive session is active, smolvm may not support concurrent exec."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the compressed tar inside the VM.
|
|
||||||
# tar exits 1 when files change during archiving (normal for a live
|
|
||||||
# filesystem); only treat exit > 1 as fatal.
|
|
||||||
tar_result = machine_exec(
|
|
||||||
machine,
|
|
||||||
[
|
|
||||||
"tar", "--create", "--gzip",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
# /tmp and /var/tmp are ephemeral. Their stale contents
|
|
||||||
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
|
|
||||||
# pack process, causing Claude Code to refuse to use them
|
|
||||||
# on resume. Exclude both; _init_vm recreates them with
|
|
||||||
# mkdir -p + correct ownership on every boot.
|
|
||||||
"--exclude=./tmp",
|
|
||||||
"--exclude=./var/tmp",
|
|
||||||
f"--file={_VM_COMMIT_TAR}",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if tar_result.returncode > 1:
|
|
||||||
die(
|
|
||||||
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
|
|
||||||
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy from VM to host, then clean up.
|
|
||||||
try:
|
|
||||||
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
|
|
||||||
finally:
|
|
||||||
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
"""End-to-end launch flow for the smolmachines backend
|
|
||||||
(PRD 0023 chunks 2d + 4b).
|
|
||||||
|
|
||||||
Brings up the per-bottle docker bridge + sidecar bundle (with
|
|
||||||
real daemons + their config files), creates + starts the smolvm
|
|
||||||
guest pointed at the bundle's pinned IP via TSI's
|
|
||||||
`--allow-cidr <bundle-ip>/32` allowlist, yields a
|
|
||||||
`SmolmachinesBottle` handle, tears everything down on context
|
|
||||||
exit.
|
|
||||||
|
|
||||||
The bundle's daemons consume the inner Plans the docker backend
|
|
||||||
already produces: egress reads routes + CAs from the EgressPlan.
|
|
||||||
Git-gate + supervise plumb through the same plans the docker
|
|
||||||
backend uses, minus the docker-network fields that don't apply here."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
|
||||||
|
|
||||||
from ...egress import (
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_resolve_token_values,
|
|
||||||
)
|
|
||||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..docker import util as docker_mod
|
|
||||||
from ..docker.egress import (
|
|
||||||
EGRESS_CA_IN_CONTAINER,
|
|
||||||
EGRESS_PORT as _EGRESS_PORT,
|
|
||||||
egress_tls_init,
|
|
||||||
)
|
|
||||||
from ..docker.git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
|
||||||
from ...log import info, warn
|
|
||||||
from ...bottle_state import (
|
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from . import loopback_alias as _loopback
|
|
||||||
from . import sidecar_bundle as _bundle
|
|
||||||
from . import smolvm as _smolvm
|
|
||||||
from .bottle import SmolmachinesBottle
|
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
|
||||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
|
||||||
|
|
||||||
|
|
||||||
# Repo root, used as the `docker build` context for the agent image.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
# Per-host cache for `smolvm pack create` outputs. Keyed by the
|
|
||||||
# docker image ID so a Dockerfile change automatically invalidates
|
|
||||||
# the cache. `pack create` is idempotent on the smolvm side but
|
|
||||||
# takes several seconds even on a no-op rebuild.
|
|
||||||
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines"
|
|
||||||
|
|
||||||
|
|
||||||
# Container-internal listening ports for each bundle daemon. The
|
|
||||||
# bundle publishes each one on a random host loopback port (see
|
|
||||||
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
|
|
||||||
# them up post-start.
|
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
_SUPERVISE_PORT = SUPERVISE_PORT
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
plan: SmolmachinesBottlePlan,
|
|
||||||
*,
|
|
||||||
provision: Callable[[SmolmachinesBottlePlan, "SmolmachinesBottle"], str | None],
|
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
|
||||||
"""Build + run the bottle and yield a handle; tear everything
|
|
||||||
down on exit. Errors during bringup unwind any partial state
|
|
||||||
via the ExitStack."""
|
|
||||||
stack = ExitStack()
|
|
||||||
try:
|
|
||||||
loopback_ip, network = _allocate_resources(plan, stack)
|
|
||||||
plan = _mint_certs(plan)
|
|
||||||
plan = _start_bundle(plan, network, loopback_ip, stack)
|
|
||||||
plan = _discover_urls(plan, loopback_ip)
|
|
||||||
|
|
||||||
agent_from_path = _agent_from_path(plan)
|
|
||||||
|
|
||||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
|
||||||
_init_vm(plan)
|
|
||||||
|
|
||||||
bottle = SmolmachinesBottle(
|
|
||||||
plan.machine_name,
|
|
||||||
prompt_path=None,
|
|
||||||
guest_env=plan.guest_env,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
yield bottle
|
|
||||||
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()
|
|
||||||
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
|
||||||
teardown_exc = exc
|
|
||||||
warn(f"smolmachines teardown failed: {exc!r}")
|
|
||||||
bottle = plan.manifest.bottle
|
|
||||||
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
|
|
||||||
def _allocate_resources(
|
|
||||||
plan: SmolmachinesBottlePlan,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
|
||||||
|
|
||||||
macOS only routes 127.0.0.1 by default; the per-bottle alias
|
|
||||||
scopes TSI's allowlist to this bottle's published ports so the
|
|
||||||
agent can't reach other bottles' or host services' ports on
|
|
||||||
loopback. No-op on Linux."""
|
|
||||||
_loopback.ensure_pool()
|
|
||||||
loopback_ip = _loopback.allocate(plan.slug)
|
|
||||||
network = _bundle.bundle_network_name(plan.slug)
|
|
||||||
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
|
||||||
stack.callback(_bundle.remove_bundle_network, network)
|
|
||||||
return loopback_ip, network
|
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
|
|
||||||
"""Mint the egress MITM CA and return the plan with CA paths filled."""
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
egress_plan = dataclasses.replace(
|
|
||||||
plan.egress_plan,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_bundle(
|
|
||||||
plan: SmolmachinesBottlePlan,
|
|
||||||
network: str,
|
|
||||||
loopback_ip: str,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> SmolmachinesBottlePlan:
|
|
||||||
"""Build the BundleLaunchSpec, resolve token env, start the
|
|
||||||
sidecar bundle container, and register teardown."""
|
|
||||||
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
|
|
||||||
token_env = _resolve_token_env(plan, dict(os.environ))
|
|
||||||
_bundle.ensure_bundle_image(bundle_spec.image)
|
|
||||||
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
|
||||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_urls(
|
|
||||||
plan: SmolmachinesBottlePlan,
|
|
||||||
loopback_ip: str,
|
|
||||||
) -> SmolmachinesBottlePlan:
|
|
||||||
"""Discover host-side ports for published container ports and
|
|
||||||
return the plan with URLs + guest_env stamped in.
|
|
||||||
|
|
||||||
Docker container IPs (192.168.x.x in the daemon's bridge)
|
|
||||||
aren't reachable from the smolvm guest on macOS — TSI uses
|
|
||||||
macOS networking, and macOS sees the daemon's bridge via the
|
|
||||||
published-port loopback forward only.
|
|
||||||
|
|
||||||
NO_PROXY includes the per-bottle loopback alias so the
|
|
||||||
supervise + git-gate URLs bypass HTTPS_PROXY."""
|
|
||||||
agent_facing_host_port = _bundle.bundle_host_port(
|
|
||||||
plan.slug, _EGRESS_PORT, host_ip=loopback_ip,
|
|
||||||
)
|
|
||||||
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
|
|
||||||
|
|
||||||
agent_git_gate_host = ""
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
git_gate_host_port = _bundle.bundle_host_port(
|
|
||||||
plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
|
|
||||||
)
|
|
||||||
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
|
||||||
|
|
||||||
agent_supervise_url = ""
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
supervise_host_port = _bundle.bundle_host_port(
|
|
||||||
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
|
|
||||||
)
|
|
||||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
|
||||||
|
|
||||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
|
||||||
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
|
||||||
guest_env = {
|
|
||||||
**plan.guest_env,
|
|
||||||
"HTTPS_PROXY": agent_proxy_url,
|
|
||||||
"HTTP_PROXY": agent_proxy_url,
|
|
||||||
"https_proxy": agent_proxy_url,
|
|
||||||
"http_proxy": agent_proxy_url,
|
|
||||||
"NO_PROXY": no_proxy,
|
|
||||||
"no_proxy": no_proxy,
|
|
||||||
}
|
|
||||||
if agent_git_gate_host:
|
|
||||||
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
|
|
||||||
if agent_supervise_url:
|
|
||||||
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
|
|
||||||
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
guest_env=guest_env,
|
|
||||||
agent_proxy_url=agent_proxy_url,
|
|
||||||
agent_git_gate_host=agent_git_gate_host,
|
|
||||||
agent_supervise_url=agent_supervise_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_vm(
|
|
||||||
plan: SmolmachinesBottlePlan,
|
|
||||||
agent_from_path: Path,
|
|
||||||
loopback_ip: str,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> None:
|
|
||||||
"""Create, patch, and start the smolvm VM; register teardown.
|
|
||||||
|
|
||||||
--allow-cidr is the per-bottle loopback alias so the guest can
|
|
||||||
only reach this bottle's bundle ports. force_allowlist patches
|
|
||||||
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
|
|
||||||
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
|
|
||||||
and --smolfile mutually exclusive."""
|
|
||||||
_smolvm.machine_create(
|
|
||||||
plan.machine_name,
|
|
||||||
from_path=agent_from_path,
|
|
||||||
allow_cidrs=[f"{loopback_ip}/32"],
|
|
||||||
env=plan.guest_env,
|
|
||||||
)
|
|
||||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
|
||||||
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
|
|
||||||
# when combined with `--from`. Patch the persisted state DB
|
|
||||||
# before start so the booted VM's TSI actually enforces.
|
|
||||||
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
|
|
||||||
_smolvm.machine_start(plan.machine_name)
|
|
||||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
|
||||||
"""Repair filesystem ownership and wait for exec channel readiness.
|
|
||||||
|
|
||||||
Ownership repair: smolvm's pack process remaps files to the host
|
|
||||||
invoker's uid (501 on macOS). /home/node must be node:node so
|
|
||||||
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
|
||||||
mode 1777 so non-root processes can create per-uid scratch dirs.
|
|
||||||
All folded into one sh -c to avoid back-to-back exec calls
|
|
||||||
immediately after machine_start (libkrun exec-channel race).
|
|
||||||
|
|
||||||
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
|
||||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
|
||||||
stale contents would have wrong uid after smolvm's uid remap). The
|
|
||||||
directories must be created before chown/chmod can set permissions.
|
|
||||||
|
|
||||||
wait_exec_ready polls until the exec channel is ready for the
|
|
||||||
subsequent provision calls, replacing the empirical sleep."""
|
|
||||||
_smolvm.machine_exec(plan.machine_name, [
|
|
||||||
"sh", "-c",
|
|
||||||
"mkdir -p /tmp /var/tmp && "
|
|
||||||
"chown -R node:node /home/node && "
|
|
||||||
"chown root:root /tmp /var/tmp && "
|
|
||||||
"chmod 1777 /tmp /var/tmp",
|
|
||||||
])
|
|
||||||
_smolvm.wait_exec_ready(plan.machine_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _bundle_launch_spec(
|
|
||||||
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
|
|
||||||
) -> _bundle.BundleLaunchSpec:
|
|
||||||
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
|
||||||
|
|
||||||
Daemons in the CSV:
|
|
||||||
- egress is always present.
|
|
||||||
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
|
|
||||||
- supervise is conditional on plan.supervise_plan.
|
|
||||||
|
|
||||||
Env + volumes are the union of the sidecar daemons' needs, with
|
|
||||||
daemon-private values only (HTTPS_PROXY is scoped to the
|
|
||||||
egress process by egress_entrypoint.sh — see PRD 0024's bundle
|
|
||||||
bind-address PR)."""
|
|
||||||
daemons: list[str] = ["egress"]
|
|
||||||
env: list[str] = []
|
|
||||||
volumes: list[tuple[str, str, bool]] = []
|
|
||||||
|
|
||||||
# --- egress -----------------------------------------------
|
|
||||||
ep = plan.egress_plan
|
|
||||||
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
|
||||||
if ep.routes:
|
|
||||||
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
|
||||||
# Bare-name entries for upstream-token slots. Their values
|
|
||||||
# come from the docker-run subprocess env (inherited from
|
|
||||||
# the operator's shell), never landing on argv.
|
|
||||||
for token_env in sorted(ep.token_env_map.keys()):
|
|
||||||
env.append(token_env)
|
|
||||||
|
|
||||||
# --- git-gate ---------------------------------------------
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if gp.upstreams:
|
|
||||||
daemons += ["git-gate", "git-http"]
|
|
||||||
volumes += [
|
|
||||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
|
|
||||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
|
|
||||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
|
|
||||||
]
|
|
||||||
for u in gp.upstreams:
|
|
||||||
keypath = expand_tilde(u.identity_file)
|
|
||||||
volumes.append((
|
|
||||||
keypath,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
if u.known_hosts_file:
|
|
||||||
volumes.append((
|
|
||||||
str(u.known_hosts_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
|
|
||||||
# --- supervise --------------------------------------------
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
|
||||||
|
|
||||||
# Container ports the agent reaches from the smolvm guest —
|
|
||||||
# published on host loopback so the guest can dial via TSI +
|
|
||||||
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
|
|
||||||
ports_to_publish: list[int] = [_EGRESS_PORT]
|
|
||||||
if gp.upstreams:
|
|
||||||
ports_to_publish.append(_GIT_HTTP_PORT)
|
|
||||||
if sp is not None:
|
|
||||||
ports_to_publish.append(_SUPERVISE_PORT)
|
|
||||||
|
|
||||||
return _bundle.BundleLaunchSpec(
|
|
||||||
slug=plan.slug,
|
|
||||||
network_name=network,
|
|
||||||
subnet=plan.bundle_subnet,
|
|
||||||
gateway=plan.bundle_gateway,
|
|
||||||
bundle_ip=plan.bundle_ip,
|
|
||||||
daemons_csv=",".join(daemons),
|
|
||||||
environment=tuple(env),
|
|
||||||
volumes=tuple(volumes),
|
|
||||||
ports_to_publish=tuple(ports_to_publish),
|
|
||||||
publish_host_ip=loopback_ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_token_env(
|
|
||||||
plan: SmolmachinesBottlePlan, host_env: dict[str, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Resolve the egress token env-var values from the host's
|
|
||||||
environ so they reach the bundle's process env via docker's
|
|
||||||
`-e NAME` inheritance. Empty when no routes declare auth."""
|
|
||||||
effective_env = {**host_env, **plan.agent_provision.provisioned_env}
|
|
||||||
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
|
||||||
"""Return the `.smolmachine` artifact used for `machine create --from`.
|
|
||||||
|
|
||||||
Prefer a committed VM artifact when one is recorded and still
|
|
||||||
present. If the file was removed, fall back to the normal image
|
|
||||||
build + pack cache path.
|
|
||||||
"""
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed:
|
|
||||||
committed_path = Path(committed)
|
|
||||||
if committed_path.is_file():
|
|
||||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
|
||||||
return committed_path
|
|
||||||
|
|
||||||
# Build the agent image and pack it into a `.smolmachine`
|
|
||||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
|
||||||
# not in prepare, so the docker-build output doesn't garble the
|
|
||||||
# dashboard's preflight modal.
|
|
||||||
return _ensure_smolmachine(
|
|
||||||
plan.agent_image,
|
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
|
||||||
"""Build the agent docker image and convert it into a
|
|
||||||
`.smolmachine` artifact, caching the result under
|
|
||||||
`~/.cache/bot-bottle/smolmachines/` keyed by the docker image
|
|
||||||
ID (so a Dockerfile change automatically invalidates the cache).
|
|
||||||
|
|
||||||
Returns the `.smolmachine.smolmachine` sidecar path — that's
|
|
||||||
the file `machine create --from` consumes (pack create produces
|
|
||||||
a launcher binary at `.smolmachine` plus the sidecar alongside
|
|
||||||
it; the sidecar is the actual artifact).
|
|
||||||
|
|
||||||
Conversion path: `docker build` (the existing layer cache
|
|
||||||
makes no-change rebuilds cheap) → `docker save` to a tarball
|
|
||||||
→ spin up an ephemeral registry on a private docker network →
|
|
||||||
`crane push --insecure` from a one-shot container on the same
|
|
||||||
network → `smolvm pack create --image localhost:<host port>/...`
|
|
||||||
→ tear down the registry + network. The crane push detour
|
|
||||||
sidesteps the Docker-Desktop daemon's HTTPS preference for
|
|
||||||
non-loopback registries — see the `local_registry` module
|
|
||||||
docstring for the gory details.
|
|
||||||
|
|
||||||
Each pack-create costs several seconds even on a hot cache,
|
|
||||||
so we skip the whole pipeline when the cached sidecar is
|
|
||||||
already on disk for this image ID."""
|
|
||||||
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
|
|
||||||
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
|
||||||
# keep filenames manageable, long enough to make collisions
|
|
||||||
# astronomically unlikely.
|
|
||||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
|
||||||
binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine"
|
|
||||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
|
||||||
if sidecar.is_file():
|
|
||||||
return sidecar
|
|
||||||
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
|
|
||||||
docker_mod.save(image_ref, str(tarball))
|
|
||||||
try:
|
|
||||||
with ephemeral_registry() as handle:
|
|
||||||
push_ref = f"{handle.push_endpoint}/bot-bottle:{digest}"
|
|
||||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle:{digest}"
|
|
||||||
crane_push_tarball(handle, str(tarball), push_ref)
|
|
||||||
_smolvm.pack_create(pack_ref, binary)
|
|
||||||
finally:
|
|
||||||
# Tarball is ~500MB-1GB for the agent image; reclaim once
|
|
||||||
# the smolmachine artifact exists. The artifact itself is
|
|
||||||
# the long-lived cache entry.
|
|
||||||
tarball.unlink(missing_ok=True)
|
|
||||||
return sidecar
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
"""Ephemeral local OCI registry for the smolmachines agent-image
|
|
||||||
conversion path (PRD 0023 chunk 4c).
|
|
||||||
|
|
||||||
`smolvm pack create --image <ref>` only accepts OCI registry refs
|
|
||||||
— it can't read the local docker daemon's image cache, an OCI
|
|
||||||
layout directory, or a `docker save` tarball. To convert the
|
|
||||||
agent's Dockerfile-built image into a `.smolmachine` artifact we
|
|
||||||
spin up a short-lived `registry:2.8.3` container alongside a
|
|
||||||
`crane` helper container on a private docker network, push via
|
|
||||||
`crane push --insecure <tarball> <registry-container>:5000/...`,
|
|
||||||
and let smolvm pull from the registry's published host port. The
|
|
||||||
network + both containers are torn down after the pack completes.
|
|
||||||
|
|
||||||
Why this two-container dance instead of plain `docker push`:
|
|
||||||
- Docker Desktop's daemon runs in its own Linux VM, so its
|
|
||||||
`localhost` is not the host's loopback. A registry bound to
|
|
||||||
the host's 127.0.0.1 is unreachable from the daemon side.
|
|
||||||
- `host.docker.internal` is reachable from the daemon but isn't
|
|
||||||
in Docker's default insecure-registries CIDRs (only `::1/128`
|
|
||||||
and `127.0.0.0/8` are), so `docker push` to it tries HTTPS,
|
|
||||||
hits a plain-HTTP registry, and dies with
|
|
||||||
`http: server gave HTTP response to HTTPS client`. Adding
|
|
||||||
`host.docker.internal` to daemon.json works but is a one-time
|
|
||||||
manual step the user has to do in Docker Desktop's UI.
|
|
||||||
- Going through a docker network sidesteps the host-vs-daemon
|
|
||||||
loopback mismatch (crane and registry containers see each
|
|
||||||
other on the network) AND the HTTPS preference (crane has an
|
|
||||||
`--insecure` flag that forces plain HTTP).
|
|
||||||
|
|
||||||
The registry is also published on a random host port so smolvm
|
|
||||||
— a host process — can pull from `localhost:<port>` via Docker's
|
|
||||||
port-forward. smolvm's bundled crane auto-falls-back to HTTP for
|
|
||||||
localhost addresses, so no insecure-registries config is needed
|
|
||||||
on that side either."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from ...log import die
|
|
||||||
|
|
||||||
|
|
||||||
# registry:2.8.3, pinned by digest. Same env-override pattern as the
|
|
||||||
# sidecar image pin in bot_bottle/backend/docker/sidecar_bundle.py.
|
|
||||||
REGISTRY_IMAGE = os.environ.get(
|
|
||||||
"BOT_BOTTLE_REGISTRY_IMAGE",
|
|
||||||
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# gcr.io/go-containerregistry/crane:latest, pinned by digest. ~10MB,
|
|
||||||
# stable upstream from Google; we only invoke `crane push --insecure`
|
|
||||||
# against a localhost-equivalent registry, so the trust surface is
|
|
||||||
# narrow.
|
|
||||||
CRANE_IMAGE = os.environ.get(
|
|
||||||
"BOT_BOTTLE_CRANE_IMAGE",
|
|
||||||
(
|
|
||||||
"gcr.io/go-containerregistry/crane@sha256:"
|
|
||||||
"0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Internal port the registry binds to inside its container — fixed
|
|
||||||
# by the registry:2 image. The host-side mapping is random.
|
|
||||||
_REGISTRY_CONTAINER_PORT = "5000"
|
|
||||||
|
|
||||||
|
|
||||||
# How long to wait for the registry's HTTP layer to bind before
|
|
||||||
# giving up. Two seconds is empirically enough; 10s leaves headroom
|
|
||||||
# for slow CI runners without making the failure mode chatty.
|
|
||||||
_READY_TIMEOUT_S = 10.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RegistryHandle:
|
|
||||||
"""Everything callers need to push to + pull from the ephemeral
|
|
||||||
registry.
|
|
||||||
|
|
||||||
`network` is the per-session docker network — a `crane push`
|
|
||||||
container has to join it to reach the registry by name.
|
|
||||||
`push_endpoint` is the `<host>:<port>` form to embed in image
|
|
||||||
refs given to the crane push container (resolves via docker
|
|
||||||
network DNS). `pull_endpoint` is the `<host>:<port>` form a
|
|
||||||
host process (smolvm) uses; the registry's host port mapping
|
|
||||||
backs this."""
|
|
||||||
|
|
||||||
network: str
|
|
||||||
push_endpoint: str
|
|
||||||
pull_endpoint: str
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def ephemeral_registry() -> Generator[RegistryHandle, None, None]:
|
|
||||||
"""Bring up a per-session docker network + a `registry:2.8.3`
|
|
||||||
container on it (published on a random host port), yield a
|
|
||||||
`RegistryHandle`, force-remove both on exit.
|
|
||||||
|
|
||||||
The container is started with `--rm` so a clean exit cleans up
|
|
||||||
on its own; the `finally` block force-removes on abnormal exit
|
|
||||||
(the calling process crashes between yield and close)."""
|
|
||||||
session_id = uuid.uuid4().hex[:12]
|
|
||||||
network = f"bot-bottle-registry-net-{session_id}"
|
|
||||||
registry_name = f"bot-bottle-registry-{session_id}"
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "network", "create", network],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "-d", "--rm",
|
|
||||||
"--name", registry_name,
|
|
||||||
"--network", network,
|
|
||||||
# `-p :5000` (no IP prefix) binds the container's
|
|
||||||
# port 5000 on a random host port across all
|
|
||||||
# interfaces. The host side reaches the registry
|
|
||||||
# via this port — smolvm's `pack create` pulls from
|
|
||||||
# `localhost:<port>` and the docker port-forward
|
|
||||||
# routes there.
|
|
||||||
"-p", _REGISTRY_CONTAINER_PORT,
|
|
||||||
REGISTRY_IMAGE,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
port = _host_port(registry_name)
|
|
||||||
_wait_ready(port)
|
|
||||||
yield RegistryHandle(
|
|
||||||
network=network,
|
|
||||||
push_endpoint=f"{registry_name}:{_REGISTRY_CONTAINER_PORT}",
|
|
||||||
pull_endpoint=f"localhost:{port}",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", registry_name],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "network", "rm", network],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None:
|
|
||||||
"""Run `crane push --insecure <tarball> <ref>` inside a one-shot
|
|
||||||
container on the registry's docker network. `ref` should
|
|
||||||
reference the registry by `handle.push_endpoint` so the crane
|
|
||||||
container resolves it via docker network DNS.
|
|
||||||
|
|
||||||
Doesn't go through `docker push` to avoid the Docker-Desktop
|
|
||||||
daemon's HTTPS preference for non-loopback hostnames — crane's
|
|
||||||
`--insecure` flag forces plain HTTP, which is what the
|
|
||||||
registry container speaks."""
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "--rm",
|
|
||||||
"--network", handle.network,
|
|
||||||
"-v", f"{tarball_path}:/img.tar:ro",
|
|
||||||
CRANE_IMAGE,
|
|
||||||
"push", "--insecure", "/img.tar", ref,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"crane push of {tarball_path!r} to {ref!r} failed: "
|
|
||||||
f"{(r.stderr or r.stdout or '').strip() or '<no output>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _host_port(name: str) -> int:
|
|
||||||
"""Resolve the host-side port docker mapped to the registry's
|
|
||||||
container port. `docker port <name> 5000/tcp` returns one or
|
|
||||||
more `host:port` lines (one per address family) — we take the
|
|
||||||
first."""
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "port", name, f"{_REGISTRY_CONTAINER_PORT}/tcp"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"docker port {name} {_REGISTRY_CONTAINER_PORT}/tcp failed: "
|
|
||||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
# `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to
|
|
||||||
# handle either IPv4 or IPv6 host syntax.
|
|
||||||
line = (r.stdout or "").splitlines()[0].strip()
|
|
||||||
_, _, port_str = line.rpartition(":")
|
|
||||||
try:
|
|
||||||
return int(port_str)
|
|
||||||
except ValueError:
|
|
||||||
die(f"unexpected `docker port` output: {line!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_ready(port: int) -> None:
|
|
||||||
"""Block until the registry's HTTP layer accepts a TCP
|
|
||||||
connection on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S`
|
|
||||||
elapses.
|
|
||||||
|
|
||||||
A successful TCP connect is sufficient — registry:2.8.3 binds
|
|
||||||
after it's ready to serve `/v2/` requests, so the push that
|
|
||||||
follows will land on a working server. We probe loopback
|
|
||||||
specifically (not via the docker network) because this helper
|
|
||||||
runs on the host."""
|
|
||||||
deadline = time.monotonic() + _READY_TIMEOUT_S
|
|
||||||
last_err: Exception | None = None
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
||||||
return
|
|
||||||
except OSError as e:
|
|
||||||
last_err = e
|
|
||||||
time.sleep(0.1)
|
|
||||||
die(
|
|
||||||
f"local registry on 127.0.0.1:{port} did not accept "
|
|
||||||
f"connections within {_READY_TIMEOUT_S:.0f}s "
|
|
||||||
f"(last error: {last_err})"
|
|
||||||
)
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""Per-bottle loopback alias allocation + TSI allowlist
|
|
||||||
enforcement (PRD 0023, follow-up to PR #74).
|
|
||||||
|
|
||||||
After the pivot to host-loopback port-forwards, the smolmachines
|
|
||||||
TSI allowlist was `127.0.0.1/32` — which meant the agent VM could
|
|
||||||
reach **any** service bound to macOS's loopback, not just the
|
|
||||||
bundle's published ports. Real downgrade from the docker
|
|
||||||
backend's `--internal` network isolation.
|
|
||||||
|
|
||||||
This module narrows the allowlist by allocating each bottle a
|
|
||||||
unique loopback alias (`127.0.0.16` .. `127.0.0.31`). The
|
|
||||||
bundle's port-forwards bind to that alias, and the alias's /32
|
|
||||||
is what TSI allows.
|
|
||||||
|
|
||||||
**Smolvm 0.8.0 quirk + workaround.** `smolvm machine create
|
|
||||||
--from <smolmachine> --net --allow-cidr X/32` silently drops the
|
|
||||||
flag — verified empirically that the agent process's allowlist
|
|
||||||
ends up `null` in smolvm's persistent state DB (`~/Library/
|
|
||||||
Application Support/smolvm/server/smolvm.db`, `vms` table,
|
|
||||||
`data` BLOB), and the booted VM reaches all of `127.0.0.0/8`
|
|
||||||
regardless of what we passed. Workaround: after machine_create,
|
|
||||||
open the SQLite DB and patch the row's `allowed_cidrs` field
|
|
||||||
directly. Smolvm reads the DB at machine_start, so the patched
|
|
||||||
value takes effect on boot. Tested: enforcement is real — the
|
|
||||||
guest's connect to a non-allowlisted IP fails with `Permission
|
|
||||||
denied`. Other paths we tried (machine update, stop-edit-
|
|
||||||
agent.config.json-restart, --smolfile, --image localhost:N/...)
|
|
||||||
were dead ends.
|
|
||||||
|
|
||||||
macOS only configures `127.0.0.1` on `lo0` by default; the
|
|
||||||
additional aliases require `sudo ifconfig lo0 alias`. We lazily
|
|
||||||
sudo-add the missing pool on first use per boot — the aliases
|
|
||||||
persist on `lo0` until reboot, so subsequent launches don't
|
|
||||||
prompt.
|
|
||||||
|
|
||||||
Linux native daemons share the host's network namespace; the
|
|
||||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
|
||||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
|
||||||
entirely; the DB patch is also gated on macOS.
|
|
||||||
|
|
||||||
Allocation is coordinated by inspecting running bundle
|
|
||||||
containers' published host IPs — each bottle's bundle owns the
|
|
||||||
alias appearing in its port bindings. The lowest-numbered free
|
|
||||||
alias gets handed to a new bottle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
|
|
||||||
# table holds one JSON BLOB per machine. The Linux path is
|
|
||||||
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
|
|
||||||
# we hard-code this. If the file moves under us we'll see a
|
|
||||||
# clear FileNotFoundError; not worth defensive cross-platform
|
|
||||||
# detection until the backend actually needs Linux.
|
|
||||||
_SMOLVM_DB_PATH = (
|
|
||||||
Path.home()
|
|
||||||
/ "Library"
|
|
||||||
/ "Application Support"
|
|
||||||
/ "smolvm"
|
|
||||||
/ "server"
|
|
||||||
/ "smolvm.db"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Sixteen aliases by default. Tunable for hosts that want more
|
|
||||||
# concurrent bottles (each bottle reserves one alias for its
|
|
||||||
# bundle bringup). The range is chosen to avoid the reserved
|
|
||||||
# 127.0.0.1/2/3 ports (1 is the default, 2 is sometimes used by
|
|
||||||
# CUPS, 3 by other macOS services) and stay well clear of
|
|
||||||
# 127.0.0.53 (systemd-resolved) and 127.0.0.54 (libvirt).
|
|
||||||
_POOL_START = 16
|
|
||||||
_POOL_END = 31 # inclusive
|
|
||||||
|
|
||||||
|
|
||||||
# File lock that serialises concurrent allocate() calls so two
|
|
||||||
# simultaneous launches can't read the same docker state and claim
|
|
||||||
# the same alias. Narrowed to the allocate() call itself; docker run
|
|
||||||
# runs after the lock is released. Once the container is running it
|
|
||||||
# appears in docker state and future allocate() calls will see it.
|
|
||||||
_ALLOC_LOCK_PATH = Path.home() / ".cache" / "bot-bottle" / "smolmachines.lock"
|
|
||||||
|
|
||||||
|
|
||||||
# Loopback aliases pool: 127.0.0.<start>..127.0.0.<end>.
|
|
||||||
def _pool_addresses() -> list[str]:
|
|
||||||
return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_macos() -> bool:
|
|
||||||
return platform.system() == "Darwin"
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_pool() -> None:
|
|
||||||
"""Make sure each address in the pool is up on `lo0`. Lazily
|
|
||||||
runs `sudo ifconfig lo0 alias <ip>/32 up` for missing entries
|
|
||||||
(sudo prompts once, then the aliases persist on lo0 until
|
|
||||||
reboot). No-op on non-macOS hosts."""
|
|
||||||
if not _is_macos():
|
|
||||||
return
|
|
||||||
missing = [ip for ip in _pool_addresses() if not _alias_present(ip)]
|
|
||||||
if not missing:
|
|
||||||
return
|
|
||||||
info(
|
|
||||||
f"smolmachines needs {len(missing)} loopback alias(es) on lo0 "
|
|
||||||
f"({', '.join(missing[:3])}{', ...' if len(missing) > 3 else ''}) "
|
|
||||||
f"to scope per-bottle TSI allowlists. sudo will prompt once; "
|
|
||||||
f"aliases persist until reboot."
|
|
||||||
)
|
|
||||||
for ip in missing:
|
|
||||||
result = subprocess.run(
|
|
||||||
["sudo", "-p", "bot-bottle (loopback alias): ",
|
|
||||||
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"sudo ifconfig lo0 alias {ip} failed (exit "
|
|
||||||
f"{result.returncode}). Re-run with sudo available, "
|
|
||||||
f"or add manually: sudo ifconfig lo0 alias {ip}/32 up"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
|
||||||
"""Patch smolvm's persistent VM-state DB to set the machine's
|
|
||||||
`allowed_cidrs` to the given list. Workaround for smolvm
|
|
||||||
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
|
|
||||||
|
|
||||||
Must run AFTER `smolvm machine create` (the row has to
|
|
||||||
exist) and BEFORE `smolvm machine start` (smolvm reads the
|
|
||||||
row on start; in-flight VMs don't pick up changes). Once
|
|
||||||
smolvm honors the CLI flag upstream this whole function is
|
|
||||||
redundant — flag-respecting create + remove this call from
|
|
||||||
launch.
|
|
||||||
|
|
||||||
No-op on non-macOS — the DB path differs and the Linux
|
|
||||||
smolmachines code path isn't exercised in v1."""
|
|
||||||
if not _is_macos():
|
|
||||||
return
|
|
||||||
if not _SMOLVM_DB_PATH.is_file():
|
|
||||||
die(
|
|
||||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
|
|
||||||
f"smolvm 0.8.0 expected? `smolvm --version` to check."
|
|
||||||
)
|
|
||||||
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
|
||||||
try:
|
|
||||||
cur = con.cursor()
|
|
||||||
row = cur.execute(
|
|
||||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
die(
|
|
||||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
|
||||||
f"machine_create must run before force_allowlist."
|
|
||||||
)
|
|
||||||
cfg = json.loads(row[0])
|
|
||||||
cfg["allowed_cidrs"] = list(allowed_cidrs)
|
|
||||||
# Write as BLOB (the column type smolvm uses) — passing a
|
|
||||||
# plain str makes sqlite store it as Text and smolvm then
|
|
||||||
# fails to read it.
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE vms SET data = ? WHERE name = ?",
|
|
||||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
|
||||||
)
|
|
||||||
con.commit()
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
def allocate(_slug: str) -> str:
|
|
||||||
"""Pick the lowest-numbered alias from the pool not already
|
|
||||||
in use by a running smolmachines bundle. Bails when the pool
|
|
||||||
is exhausted — the caller should report the limit to the
|
|
||||||
operator. `_slug` is logged for traceability; not otherwise
|
|
||||||
used (no on-disk reservation, allocation is purely
|
|
||||||
docker-state-driven).
|
|
||||||
|
|
||||||
On non-macOS the whole `127.0.0.0/8` is loopback by default;
|
|
||||||
`127.0.0.1` is fine to share and we skip the alias dance.
|
|
||||||
This still returns a deterministic address so launch.py's
|
|
||||||
callers don't have to branch on platform.
|
|
||||||
|
|
||||||
An exclusive file lock serialises concurrent calls so two
|
|
||||||
simultaneous launches don't read the same docker state and
|
|
||||||
claim the same alias."""
|
|
||||||
if not _is_macos():
|
|
||||||
return "127.0.0.1"
|
|
||||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
|
||||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
|
||||||
return _allocate_locked()
|
|
||||||
|
|
||||||
|
|
||||||
def _allocate_locked() -> str:
|
|
||||||
in_use = _aliases_in_use()
|
|
||||||
for ip in _pool_addresses():
|
|
||||||
if ip not in in_use:
|
|
||||||
return ip
|
|
||||||
die(
|
|
||||||
f"smolmachines loopback alias pool exhausted "
|
|
||||||
f"({_POOL_END - _POOL_START + 1} aliases, all in use). "
|
|
||||||
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
|
||||||
f"raise _POOL_END in loopback_alias.py."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _alias_present(ip: str) -> bool:
|
|
||||||
"""True iff `ifconfig lo0` shows `<ip>` as an inet address.
|
|
||||||
Exact-match — `127.0.0.1` shouldn't match `127.0.0.16`."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["/sbin/ifconfig", "lo0"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
pattern = re.compile(rf"\binet {re.escape(ip)}\b")
|
|
||||||
return bool(pattern.search(result.stdout or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _aliases_in_use() -> set[str]:
|
|
||||||
"""Aliases already bound by another smolmachines bundle's
|
|
||||||
published-port mappings. We inspect every container whose
|
|
||||||
name matches the smolmachines bundle prefix and pull the
|
|
||||||
`HostIp` out of its port bindings."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "--format", "{{.Names}}",
|
|
||||||
"--filter", "name=bot-bottle-sidecars-"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return set()
|
|
||||||
names = [n.strip() for n in (result.stdout or "").splitlines() if n.strip()]
|
|
||||||
in_use: set[str] = set()
|
|
||||||
for name in names:
|
|
||||||
in_use.update(_host_ips_for_container(name))
|
|
||||||
return in_use
|
|
||||||
|
|
||||||
|
|
||||||
def _host_ips_for_container(name: str) -> Iterable[str]:
|
|
||||||
"""Yield the `HostIp` values across all port bindings on
|
|
||||||
container `name`. A bundle binds three or four ports and
|
|
||||||
they all share the same HostIp, so callers can take any."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "inspect", name,
|
|
||||||
"--format", "{{json .HostConfig.PortBindings}}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return ()
|
|
||||||
try:
|
|
||||||
bindings = json.loads(result.stdout or "{}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return ()
|
|
||||||
seen: set[str] = set()
|
|
||||||
for _port, mappings in (bindings or {}).items():
|
|
||||||
for m in mappings or []:
|
|
||||||
host_ip = m.get("HostIp") or ""
|
|
||||||
if host_ip:
|
|
||||||
seen.add(host_ip)
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["allocate", "ensure_pool", "force_allowlist"]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""Backend-infrastructure provisioners for the smolmachines backend.
|
|
||||||
|
|
||||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
|
||||||
declarative provision-plan apply, supervise MCP registration) live on
|
|
||||||
the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
|
|
||||||
provisioning also moved to the AgentProvider ABC (with Debian/node
|
|
||||||
defaults); user plugins override them for non-standard images.
|
|
||||||
|
|
||||||
No modules remain in this subpackage. Workspace copying now runs
|
|
||||||
through `BottleBackend.provision_workspace` against the running
|
|
||||||
bottle for every backend.
|
|
||||||
"""
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Host-side SIGWINCH → in-VM PTY resize bridge (issue #82).
|
|
||||||
|
|
||||||
smolvm 0.8.0 `machine exec -t` allocates an in-VM PTY but never
|
|
||||||
forwards the host terminal's window size (TIOCSWINSZ) to it. The
|
|
||||||
PTY's initial size is `0 0`, and any host-side resize during the
|
|
||||||
session goes unnoticed — the in-VM claude TUI keeps rendering for
|
|
||||||
whatever (typically tiny) box it last saw, ignoring the operator's
|
|
||||||
tmux pane resize. `docker exec -it` does this forwarding
|
|
||||||
automatically; smolvm doesn't.
|
|
||||||
|
|
||||||
This module wraps `smolvm machine exec` with a thin parent
|
|
||||||
process that:
|
|
||||||
|
|
||||||
1. Spawns the original argv as a child (it gets the inherited
|
|
||||||
TTY, so claude's stdin/stdout/stderr work unchanged).
|
|
||||||
2. On startup + every host SIGWINCH, reads the host terminal
|
|
||||||
size via TIOCGWINSZ on stdin (or stderr if stdin isn't a
|
|
||||||
TTY — tmux respawn-pane gives us a TTY on stdout/stderr)
|
|
||||||
and pushes it into the VM with a side-channel
|
|
||||||
`smolvm machine exec -- sh -c 'for f in /dev/pts/*; do
|
|
||||||
stty -F $f cols X rows Y; done'`. The kernel delivers
|
|
||||||
SIGWINCH to the foreground process group on the slave end
|
|
||||||
automatically, so claude picks up the new size without
|
|
||||||
extra signalling.
|
|
||||||
3. Waits on the child and exits with its returncode.
|
|
||||||
|
|
||||||
The dashboard's tmux pane respawn calls `bottle.agent_argv`
|
|
||||||
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
|
|
||||||
to the smolvm argv. Foreground handoff (curses endwin →
|
|
||||||
subprocess.run) goes through the same path so behavior is
|
|
||||||
identical.
|
|
||||||
|
|
||||||
Removable once smolvm grows native SIGWINCH forwarding (upstream
|
|
||||||
follow-up tracked separately)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import signal
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import threading
|
|
||||||
from types import FrameType
|
|
||||||
|
|
||||||
|
|
||||||
# How long to wait after the main exec starts before pushing the
|
|
||||||
# initial size. Concurrent `smolvm machine exec` invocations race
|
|
||||||
# libkrun's per-exec OCI config write during the main exec's
|
|
||||||
# bringup window; the side-channel firing immediately corrupts
|
|
||||||
# `config.json` and the main exec dies with SIGKILL (rc=137) or
|
|
||||||
# libkrun's "parse error: trailing garbage" depending on
|
|
||||||
# scheduling. Two seconds is well past the bringup window on a
|
|
||||||
# warm VM, well under the operator's "this is unresponsive"
|
|
||||||
# threshold, and short enough that claude's initial render
|
|
||||||
# almost always fires after the size has been set.
|
|
||||||
_STARTUP_SYNC_DELAY_SEC = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def _read_winsize() -> tuple[int, int] | None:
|
|
||||||
"""Return `(rows, cols)` from whichever of stdin / stdout /
|
|
||||||
stderr is a TTY, or None if none are. Different invocation
|
|
||||||
surfaces give us different TTYs:
|
|
||||||
|
|
||||||
- foreground handoff (curses endwin → subprocess.run): all
|
|
||||||
three are the operator's terminal.
|
|
||||||
- tmux respawn-pane: tmux sets all three to the pane's PTY.
|
|
||||||
- non-TTY (someone piped stdin in tests): none are; the
|
|
||||||
sync just no-ops, which is the right behavior."""
|
|
||||||
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
||||||
try:
|
|
||||||
fd = stream.fileno()
|
|
||||||
data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
rows, cols, _, _ = struct.unpack("hhhh", data)
|
|
||||||
if rows > 0 and cols > 0:
|
|
||||||
return rows, cols
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _push_size(machine: str, rows: int, cols: int) -> None:
|
|
||||||
"""Side-channel `smolvm machine exec` that sets the size of
|
|
||||||
every PTY in the VM. The shell `for` loop covers the case of
|
|
||||||
multiple concurrent interactive sessions (rare but cheap to
|
|
||||||
handle); `stty -F` returns silently on PTYs that don't apply.
|
|
||||||
|
|
||||||
Best-effort: swallow failures. A failed resize doesn't break
|
|
||||||
the session — it just leaves the in-VM PTY at its old size.
|
|
||||||
|
|
||||||
`stdin=DEVNULL` is load-bearing: under tmux, inheriting the
|
|
||||||
pane PTY here means two concurrent smolvm processes (this one
|
|
||||||
and the agent session the wrapper is shepherding) share the
|
|
||||||
PTY's foreground-process-group / input plumbing, and smolvm
|
|
||||||
bails with an internal config-parse error or SIGKILL within
|
|
||||||
~100ms of the side-channel firing. Outside tmux the same
|
|
||||||
pattern survived, presumably because iTerm's PTY plumbing is
|
|
||||||
more forgiving than tmux's, but the DEVNULL is the right
|
|
||||||
default either way — the side-channel never needs stdin."""
|
|
||||||
subprocess.run(
|
|
||||||
["smolvm", "machine", "exec", "--name", machine, "--",
|
|
||||||
"sh", "-c",
|
|
||||||
f"for f in /dev/pts/*; do "
|
|
||||||
f"stty -F \"$f\" cols {cols} rows {rows} 2>/dev/null; "
|
|
||||||
f"done"],
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Entry point. `argv` shape: `<machine> -- <smolvm-argv...>`.
|
|
||||||
|
|
||||||
We don't use argparse — the `--` separator is the contract and
|
|
||||||
everything past it is forwarded verbatim. Keeps the wrapper
|
|
||||||
transparent for callers building argv programmatically."""
|
|
||||||
if len(argv) < 3 or argv[1] != "--":
|
|
||||||
sys.stderr.write(
|
|
||||||
"usage: python -m bot_bottle.backend.smolmachines.pty_resize "
|
|
||||||
"<machine> -- <smolvm-argv...>\n"
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
machine = argv[0]
|
|
||||||
inner = argv[2:]
|
|
||||||
|
|
||||||
def sync(_signum: int | None = None, _frame: FrameType | None = None) -> None:
|
|
||||||
size = _read_winsize()
|
|
||||||
if size is None:
|
|
||||||
return
|
|
||||||
_push_size(machine, *size)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGWINCH, sync) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
proc = subprocess.Popen(inner)
|
|
||||||
# Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC.
|
|
||||||
# daemon=True so the timer doesn't block exit when the child
|
|
||||||
# finishes before the delay elapses.
|
|
||||||
timer = threading.Timer(_STARTUP_SYNC_DELAY_SEC, sync)
|
|
||||||
timer.daemon = True
|
|
||||||
timer.start()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return proc.wait()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
proc.send_signal(signal.SIGINT)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
|
|
||||||
|
|
||||||
Resolves the per-bottle docker subnet + bundle IP and assembles
|
|
||||||
the guest env. The agent's docker image build → smolmachine
|
|
||||||
pack pipeline runs in `launch.launch`, not here, so the
|
|
||||||
dashboard's preflight modal isn't garbled by docker-build output
|
|
||||||
before the operator has confirmed.
|
|
||||||
|
|
||||||
No VM bringup — that's `launch.launch`'s job."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import BottleSpec
|
|
||||||
from ...manifest import Manifest
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
|
||||||
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
|
||||||
|
|
||||||
def preflight() -> None:
|
|
||||||
smolmachines_preflight()
|
|
||||||
|
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
# Agent's env: resolve through resolve_env() so ?prompt entries
|
|
||||||
# are prompted and ${HOST_VAR} entries are interpolated — matching
|
|
||||||
# the Docker backend's contract. Forwarded (secret/interpolated)
|
|
||||||
# values still reach the guest as -e K=V smolvm flags because
|
|
||||||
# smolvm 0.8.0 has no env-file or stdin injection path; this is
|
|
||||||
# the known argv-exposure gap documented in PRD 0038.
|
|
||||||
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
|
|
||||||
# in launch.py after bundle bringup.
|
|
||||||
return {
|
|
||||||
**resolved_env.literals,
|
|
||||||
**resolved_env.forwarded,
|
|
||||||
"NO_PROXY": "localhost,127.0.0.1",
|
|
||||||
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
|
||||||
spec: BottleSpec,
|
|
||||||
manifest: Manifest,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> SmolmachinesBottlePlan:
|
|
||||||
"""Materialize the smolmachines plan. The bundle's docker
|
|
||||||
subnet + pinned IP are derived from the slug; the agent's
|
|
||||||
`.smolmachine` artifact is built (or cache-hit) here so
|
|
||||||
launch's `machine create --from` boots without a registry
|
|
||||||
pull. Per-bottle guest env + the TSI allow_cidrs land on the
|
|
||||||
plan for launch to pass straight through to
|
|
||||||
`machine create` flags."""
|
|
||||||
|
|
||||||
# ==== smolmachines specific setup ====
|
|
||||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
|
||||||
|
|
||||||
return SmolmachinesBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
manifest=manifest,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug=slug,
|
|
||||||
bundle_subnet=subnet,
|
|
||||||
bundle_gateway=gateway,
|
|
||||||
bundle_ip=bundle_ip,
|
|
||||||
guest_env=agent_provision_plan.guest_env,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
agent_provision=agent_provision_plan,
|
|
||||||
)
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""Per-bottle sidecar bundle bringup for the smolmachines backend
|
|
||||||
(PRD 0023).
|
|
||||||
|
|
||||||
Two docker resources per bottle live here:
|
|
||||||
|
|
||||||
- **A dedicated bridge network**, subnet derived from the slug.
|
|
||||||
The bundle container gets a pinned IP at `<subnet>.2` so the
|
|
||||||
smolvm guest's TSI allowlist (`<bundle-ip>/32`) has a stable
|
|
||||||
target. Without pinning, we'd have to inspect the container's
|
|
||||||
assigned IP after start and feed it back into the Smolfile
|
|
||||||
— a race we can sidestep with `--ip`.
|
|
||||||
|
|
||||||
- **The bundle container itself**, running the PRD 0024 bundle
|
|
||||||
image (`bot-bottle-sidecars:latest` by default). Same
|
|
||||||
image, same daemons, same daemon-private env / bind-mounts
|
|
||||||
as the docker backend.
|
|
||||||
|
|
||||||
This module ships the lifecycle primitives only — create
|
|
||||||
network, start bundle, stop bundle, remove network — wrapped
|
|
||||||
around `subprocess.run(["docker", ...])`. Wiring them into the
|
|
||||||
launch flow + populating the `BundleLaunchSpec` from the inner
|
|
||||||
Plans (EgressPlan, …) lands in chunk 2d."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from ...log import die, warn
|
|
||||||
from ..docker import util as docker_mod
|
|
||||||
from ..docker.sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def bundle_network_name(slug: str) -> str:
|
|
||||||
"""`bot-bottle-bundle-<slug>` — distinct from the docker
|
|
||||||
backend's `bot-bottle-net-<slug>` so a smolmachines bottle
|
|
||||||
and a docker bottle for the same agent don't collide on
|
|
||||||
network name."""
|
|
||||||
return f"bot-bottle-bundle-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def bundle_container_name(slug: str) -> str:
|
|
||||||
"""`bot-bottle-sidecars-<slug>` — same name shape the docker
|
|
||||||
backend uses for the bundle (PRD 0024 chunk 5). The dashboard's
|
|
||||||
prefix-based discovery covers both backends with one filter."""
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BundleLaunchSpec:
|
|
||||||
"""Everything `start_bundle` needs to bring up one bundle
|
|
||||||
container. Populated by chunk-2d's launch flow from the inner
|
|
||||||
Plans the prepare step already produces."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
network_name: str
|
|
||||||
subnet: str
|
|
||||||
gateway: str
|
|
||||||
bundle_ip: str
|
|
||||||
image: str = SIDECAR_BUNDLE_IMAGE
|
|
||||||
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
|
|
||||||
# supervisor inside the bundle reads it to skip
|
|
||||||
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
|
||||||
daemons_csv: str = "egress"
|
|
||||||
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
|
|
||||||
# form inherits the value from the docker-run subprocess env,
|
|
||||||
# matching the docker backend's compose-up secret-forwarding
|
|
||||||
# pattern).
|
|
||||||
environment: Sequence[str] = field(default_factory=tuple)
|
|
||||||
# (host_path, container_path, read_only) bind mounts.
|
|
||||||
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
|
||||||
# Container ports to publish on `publish_host_ip`, random
|
|
||||||
# host-side port per entry. The smolvm guest's TSI talks via
|
|
||||||
# macOS networking, so docker container IPs (192.168.x.x in
|
|
||||||
# the daemon's bridge) aren't directly reachable from the
|
|
||||||
# guest — host-loopback port-forwards are. Egress's port
|
|
||||||
# is bundle-internal and never published.
|
|
||||||
ports_to_publish: Sequence[int] = field(default_factory=tuple)
|
|
||||||
# Loopback IP to bind published ports against. Per-bottle
|
|
||||||
# loopback aliases (`127.0.0.16` etc., added via sudo
|
|
||||||
# ifconfig lo0 alias) narrow the TSI allowlist so a bottle
|
|
||||||
# can't reach other bottles' (or other host services') ports
|
|
||||||
# via 127.0.0.1.
|
|
||||||
publish_host_ip: str = "127.0.0.1"
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
|
||||||
"""Build the sidecar bundle image before `docker run`.
|
|
||||||
|
|
||||||
The Docker backend gets this for free from compose's `build:`
|
|
||||||
stanza. smolmachines starts the bundle with plain `docker run`,
|
|
||||||
so without an explicit build a first launch tries to pull the
|
|
||||||
local-only `bot-bottle-sidecars:latest` tag from a registry.
|
|
||||||
"""
|
|
||||||
docker_mod.build_image(
|
|
||||||
image,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
|
||||||
"""`docker network create` with an explicit subnet + gateway
|
|
||||||
so the bundle's `--ip` lands on the address the Smolfile's
|
|
||||||
TSI allowlist points at. Idempotent on the caller's side —
|
|
||||||
`start_bundle` catches the "network exists" error and treats
|
|
||||||
it as success (chunk-2d teardown is paired with each create).
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "create",
|
|
||||||
"--subnet", subnet, "--gateway", gateway,
|
|
||||||
network_name],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
# Already-exists is fine on a resume path; everything else
|
|
||||||
# is fatal — the bundle won't have an addressable network.
|
|
||||||
if "already exists" in (result.stderr or "").lower():
|
|
||||||
return
|
|
||||||
die(
|
|
||||||
f"docker network create {network_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_bundle_network(network_name: str) -> None:
|
|
||||||
"""Idempotent: a missing network returns success."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "rm", network_name],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
if "no such network" in (result.stderr or "").lower():
|
|
||||||
return
|
|
||||||
# Network with attached containers is the common non-fatal
|
|
||||||
# case during a partial teardown — warn but don't die.
|
|
||||||
warn(
|
|
||||||
f"docker network rm {network_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def start_bundle(spec: BundleLaunchSpec, *,
|
|
||||||
env: dict[str, str] | None = None) -> None:
|
|
||||||
"""Bring the bundle container up on the per-bottle bridge with
|
|
||||||
the pinned IP. Argv is built deterministically from `spec`;
|
|
||||||
`env` is the host subprocess env (forwarded values for any
|
|
||||||
bare-name entries in `spec.environment`)."""
|
|
||||||
container = bundle_container_name(spec.slug)
|
|
||||||
argv = [
|
|
||||||
"docker", "run",
|
|
||||||
"--name", container,
|
|
||||||
"--detach",
|
|
||||||
"--rm",
|
|
||||||
"--network", spec.network_name,
|
|
||||||
"--ip", spec.bundle_ip,
|
|
||||||
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={spec.daemons_csv}",
|
|
||||||
]
|
|
||||||
for entry in spec.environment:
|
|
||||||
argv += ["-e", entry]
|
|
||||||
for host_path, container_path, read_only in spec.volumes:
|
|
||||||
suffix = ":ro" if read_only else ""
|
|
||||||
argv += ["-v", f"{host_path}:{container_path}{suffix}"]
|
|
||||||
# Loopback-only host port-forwards — the smolvm guest's TSI
|
|
||||||
# uses macOS networking, and macOS loopback is the only host
|
|
||||||
# surface that round-trips into Docker Desktop's daemon VM.
|
|
||||||
# Binds to the per-bottle alias so TSI's IP-only allowlist
|
|
||||||
# narrows reachability to this bottle's bundle only.
|
|
||||||
for port in spec.ports_to_publish:
|
|
||||||
argv += ["-p", f"{spec.publish_host_ip}::{port}"]
|
|
||||||
argv.append(spec.image)
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True,
|
|
||||||
env=dict(env) if env is not None else None, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"docker run for bundle {container} failed: "
|
|
||||||
f"{(result.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def bundle_host_port(
|
|
||||||
slug: str, container_port: int, *, host_ip: str = "127.0.0.1",
|
|
||||||
) -> int:
|
|
||||||
"""`docker port <bundle> <container_port>/tcp` → the random
|
|
||||||
host-side port docker assigned for the binding on `host_ip`.
|
|
||||||
Called after `start_bundle` on each container port listed in
|
|
||||||
`BundleLaunchSpec.ports_to_publish` so the launch step can
|
|
||||||
build the agent's HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
|
|
||||||
`<host_ip>:<host port>` form."""
|
|
||||||
container = bundle_container_name(slug)
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "port", container, f"{container_port}/tcp"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"docker port {container} {container_port}/tcp failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
# Each line looks like `127.0.0.16:54321` — one per address
|
|
||||||
# family / host IP. Match on the expected host_ip prefix so
|
|
||||||
# bottles bound to per-bottle aliases pick the right line.
|
|
||||||
for raw in (result.stdout or "").splitlines():
|
|
||||||
line = raw.strip()
|
|
||||||
if line.startswith(f"{host_ip}:"):
|
|
||||||
_, _, port_str = line.rpartition(":")
|
|
||||||
try:
|
|
||||||
return int(port_str)
|
|
||||||
except ValueError:
|
|
||||||
die(f"unexpected `docker port` output: {line!r}")
|
|
||||||
die(
|
|
||||||
f"no port mapping on {host_ip} for {container} "
|
|
||||||
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_bundle(slug: str) -> None:
|
|
||||||
"""Idempotent: a missing container returns success."""
|
|
||||||
container = bundle_container_name(slug)
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "rm", "-f", container],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
if "no such container" in (result.stderr or "").lower():
|
|
||||||
return
|
|
||||||
warn(
|
|
||||||
f"docker rm -f {container} failed: "
|
|
||||||
f"{(result.stderr or '').strip()}"
|
|
||||||
)
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
"""Thin subprocess wrapper around the `smolvm` CLI (PRD 0023).
|
|
||||||
|
|
||||||
One thin Python function per smolvm subcommand the launch flow
|
|
||||||
needs. Two design choices worth flagging:
|
|
||||||
|
|
||||||
- **No daemon, no SDK.** smolvm 0.8.0 ships a `smolvm serve`
|
|
||||||
HTTP API as the long-term-clean integration target. The
|
|
||||||
project's stdlib-first ethos + the lower-overhead CLI calls
|
|
||||||
push v1 to shell out via `subprocess.run`. If a future
|
|
||||||
smolvm release makes `serve` mandatory (or significantly
|
|
||||||
faster), revisit.
|
|
||||||
|
|
||||||
- **Two return shapes.** `SmolvmRunResult` (returncode + stdout
|
|
||||||
+ stderr captured) is returned by `machine_exec` because the
|
|
||||||
caller cares about the in-VM command's exit status, and by
|
|
||||||
test helpers that introspect output. The other calls
|
|
||||||
(`machine_start`, `machine_stop`, `pack_create`, etc.) raise
|
|
||||||
`SmolvmError` on non-zero exit — failure to start a VM is
|
|
||||||
fatal to the launch flow, not something callers want to
|
|
||||||
branch on.
|
|
||||||
|
|
||||||
The wrapper is unit-tested with `subprocess.run` mocked; the
|
|
||||||
integration smoke test (chunk 2d) exercises against a real
|
|
||||||
smolvm binary."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_SMOLVM = "smolvm"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SmolvmRunResult:
|
|
||||||
"""Captured result of an in-VM command. Mirrors the structure
|
|
||||||
`Bottle.exec` returns so callers can hand it straight through."""
|
|
||||||
returncode: int
|
|
||||||
stdout: str
|
|
||||||
stderr: str
|
|
||||||
|
|
||||||
|
|
||||||
class SmolvmError(RuntimeError):
|
|
||||||
"""Raised when a smolvm subprocess returns non-zero on a path
|
|
||||||
where the caller has no useful branch to take (start failed,
|
|
||||||
pack failed, etc.). Carries the captured stderr for the
|
|
||||||
operator-facing log line."""
|
|
||||||
|
|
||||||
def __init__(self, argv: Sequence[str], result: subprocess.CompletedProcess[str]):
|
|
||||||
self.argv = list(argv)
|
|
||||||
self.returncode = result.returncode
|
|
||||||
self.stdout = result.stdout
|
|
||||||
self.stderr = result.stderr
|
|
||||||
cmd = " ".join(self.argv)
|
|
||||||
super().__init__(
|
|
||||||
f"{cmd!r} failed (exit {result.returncode}): "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _smolvm(*args: str, env: Mapping[str, str] | None = None,
|
|
||||||
check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""One subprocess call into the smolvm CLI. `check=True`
|
|
||||||
raises SmolvmError on non-zero; `check=False` returns the
|
|
||||||
CompletedProcess for the caller to inspect."""
|
|
||||||
argv = [_SMOLVM, *args]
|
|
||||||
result = subprocess.run(
|
|
||||||
argv,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=dict(env) if env is not None else None,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise SmolvmError(argv, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pack ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def pack_create(image: str, output: Path) -> None:
|
|
||||||
"""`smolvm pack create --image <image> -o <output>`. Converts
|
|
||||||
an OCI image into a self-contained `.smolmachine` artifact
|
|
||||||
smolvm can boot via `machine create --from`. Idempotent on the
|
|
||||||
smolvm side — re-running with the same image+output rebuilds
|
|
||||||
from layer cache."""
|
|
||||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
|
||||||
|
|
||||||
|
|
||||||
def pack_create_from_vm(name: str, output: Path) -> None:
|
|
||||||
"""`smolvm pack create --from-vm <name> -o <output>`.
|
|
||||||
|
|
||||||
Snapshots an existing persistent VM into a pack artifact. As
|
|
||||||
with `pack_create`, smolvm writes a launcher at `output` and the
|
|
||||||
bootable sidecar at `output.smolmachine`.
|
|
||||||
"""
|
|
||||||
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Machine lifecycle ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def machine_create(
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
image: str | None = None,
|
|
||||||
from_path: Path | None = None,
|
|
||||||
allow_cidrs: Sequence[str] = (),
|
|
||||||
env: Mapping[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""`smolvm machine create NAME [--image IMG | --from PATH]
|
|
||||||
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is positional
|
|
||||||
(the CLI's exception to the `--name` pattern other
|
|
||||||
subcommands use).
|
|
||||||
|
|
||||||
`image` (registry ref like `alpine:latest`) and `from_path`
|
|
||||||
(a `.smolmachine` artifact) are mutually exclusive — one or
|
|
||||||
the other tells smolvm what to boot. The wrapper doesn't
|
|
||||||
enforce exclusivity; smolvm errors clearly enough.
|
|
||||||
|
|
||||||
`allow_cidrs` and `env` are passed as CLI flags instead of a
|
|
||||||
Smolfile because `--from` and `--smolfile` are themselves
|
|
||||||
mutually exclusive in smolvm 0.8.0 — and we want `--from`'s
|
|
||||||
no-pull-at-start property. The flag form gives the same
|
|
||||||
result without the Smolfile complication.
|
|
||||||
|
|
||||||
`--net` is sent explicitly when `allow_cidrs` is non-empty.
|
|
||||||
smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
|
|
||||||
empirically the implication only fires when no `--from` is
|
|
||||||
set — `--from PATH --allow-cidr X/32` silently produces a
|
|
||||||
machine with `network: false` and no routes in the guest, so
|
|
||||||
the agent can't reach the bundle's pinned IP."""
|
|
||||||
args: list[str] = ["machine", "create"]
|
|
||||||
if image is not None:
|
|
||||||
args += ["--image", image]
|
|
||||||
if from_path is not None:
|
|
||||||
args += ["--from", str(from_path)]
|
|
||||||
if allow_cidrs:
|
|
||||||
args.append("--net")
|
|
||||||
for cidr in allow_cidrs:
|
|
||||||
args += ["--allow-cidr", cidr]
|
|
||||||
if env:
|
|
||||||
for k, v in env.items():
|
|
||||||
args += ["-e", f"{k}={v}"]
|
|
||||||
args.append(name)
|
|
||||||
_smolvm(*args)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named VM is in the 'running' state."""
|
|
||||||
result = _smolvm("machine", "ls", "--json", check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
machines = json.loads(result.stdout or "[]")
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return any(
|
|
||||||
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
|
|
||||||
for m in machines
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_start(name: str) -> None:
|
|
||||||
"""`smolvm machine start --name NAME`."""
|
|
||||||
_smolvm("machine", "start", "--name", name)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_stop(name: str) -> None:
|
|
||||||
"""`smolvm machine stop --name NAME`. Idempotent against
|
|
||||||
already-stopped machines: smolvm prints a notice and exits 0
|
|
||||||
in that case, so no special handling here."""
|
|
||||||
_smolvm("machine", "stop", "--name", name)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_delete(name: str) -> None:
|
|
||||||
"""`smolvm machine delete -f NAME`. NAME is positional. `-f`
|
|
||||||
skips the interactive confirmation — required for
|
|
||||||
non-interactive teardown."""
|
|
||||||
_smolvm("machine", "delete", "-f", name)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_exec(
|
|
||||||
name: str,
|
|
||||||
argv: Sequence[str],
|
|
||||||
*,
|
|
||||||
env: Mapping[str, str] | None = None,
|
|
||||||
workdir: str | None = None,
|
|
||||||
timeout: str | None = None,
|
|
||||||
) -> SmolvmRunResult:
|
|
||||||
"""`smolvm machine exec --name NAME [-w DIR] [--timeout DUR]
|
|
||||||
[-e K=V ...] -- ARGV...`. Returns the captured result rather
|
|
||||||
than raising — callers (including `Bottle.exec`) care about
|
|
||||||
the in-VM command's exit code, not just whether smolvm ran.
|
|
||||||
|
|
||||||
`env` here is in-VM env vars (`-e K=V`), not the host
|
|
||||||
subprocess env — smolvm's own argv carries them through the
|
|
||||||
VMM."""
|
|
||||||
flags: list[str] = ["machine", "exec", "--name", name]
|
|
||||||
if workdir is not None:
|
|
||||||
flags += ["-w", workdir]
|
|
||||||
if timeout is not None:
|
|
||||||
flags += ["--timeout", timeout]
|
|
||||||
if env:
|
|
||||||
for k, v in env.items():
|
|
||||||
flags += ["-e", f"{k}={v}"]
|
|
||||||
# `--` separator before the command. smolvm's CLI requires it
|
|
||||||
# so its own flag parser doesn't grab argv items that look
|
|
||||||
# like flags.
|
|
||||||
flags.append("--")
|
|
||||||
flags += list(argv)
|
|
||||||
result = _smolvm(*flags, check=False)
|
|
||||||
return SmolvmRunResult(
|
|
||||||
returncode=result.returncode,
|
|
||||||
stdout=result.stdout or "",
|
|
||||||
stderr=result.stderr or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_exec_ready(name: str, *, timeout: float = 5.0) -> None:
|
|
||||||
"""Poll `machine exec true` until exit 0 or `timeout` elapses.
|
|
||||||
|
|
||||||
Replaces `time.sleep(1.5)` after `machine_start`: libkrun's exec
|
|
||||||
channel needs a brief warm-up before back-to-back exec calls are
|
|
||||||
safe. Polling exits as soon as the channel is ready and fails
|
|
||||||
loudly if the VM never responds."""
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
delay = 0.1
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
r = machine_exec(name, ["true"])
|
|
||||||
if r.returncode == 0:
|
|
||||||
return
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
time.sleep(min(delay, remaining))
|
|
||||||
delay = min(delay * 2, 0.5)
|
|
||||||
argv = ["smolvm", "machine", "exec", "--name", name, "--", "true"]
|
|
||||||
raise SmolvmError(
|
|
||||||
argv,
|
|
||||||
subprocess.CompletedProcess(
|
|
||||||
args=argv, returncode=-1, stdout="",
|
|
||||||
stderr=f"exec channel not ready after {timeout:.0f}s — VM may have failed to boot.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_cp(src: str, dst: str) -> None:
|
|
||||||
"""`smolvm machine cp SRC DST`. Path syntax: `machine:path` to
|
|
||||||
reference a path inside the VM, bare path for the host. Both
|
|
||||||
SRC and DST are positional; either side can be machine: or
|
|
||||||
bare. Empty path is a no-op (returns immediately without
|
|
||||||
invoking smolvm)."""
|
|
||||||
if not src or not dst:
|
|
||||||
return
|
|
||||||
_smolvm("machine", "cp", src, dst)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Discovery -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
|
||||||
"""True iff `smolvm` is on PATH. Used by the integration test
|
|
||||||
suite's skip-guards."""
|
|
||||||
return shutil.which(_SMOLVM) is not None
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Slug / preflight / subnet helpers for the smolmachines backend
|
|
||||||
(PRD 0023). Kept in its own module so the renderers can be
|
|
||||||
unit-tested without importing the docker subprocess paths."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from ...log import die
|
|
||||||
|
|
||||||
|
|
||||||
def smolmachines_preflight() -> None:
|
|
||||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
|
||||||
Called from `_resolve_plan`; gives the operator a clear
|
|
||||||
install pointer rather than a cryptic FileNotFoundError
|
|
||||||
later. `gvproxy` is no longer required — see the PRD's design
|
|
||||||
pivot section."""
|
|
||||||
if shutil.which("smolvm") is not None:
|
|
||||||
return
|
|
||||||
die(
|
|
||||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
|
||||||
"PATH. Install with: "
|
|
||||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
|
||||||
"To use the legacy Docker backend instead, set "
|
|
||||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
|
||||||
"""Derive a per-bottle docker subnet + gateway IP + bundle IP
|
|
||||||
from the slug.
|
|
||||||
|
|
||||||
Returns `(subnet_cidr, gateway_ip, bundle_ip)`. The third
|
|
||||||
octet comes from SHA-256 of the slug mod 254 (skipping 17 to
|
|
||||||
avoid the docker-default bridge), so parallel bottles get
|
|
||||||
distinct /24s and `resume` reuses the same /24. The bundle
|
|
||||||
container always lands at `.2`; gateway is `.1`; the smolvm
|
|
||||||
Smolfile's `allow_cidrs` is `<bundle_ip>/32`."""
|
|
||||||
digest = hashlib.sha256(slug.encode("utf-8")).digest()
|
|
||||||
octet = (digest[0] % 254) + 1
|
|
||||||
# Skip the docker-default bridge to dodge the most common
|
|
||||||
# collision (operators with `docker0` at 172.17.x.x or a
|
|
||||||
# 192.168.17.x VPN client).
|
|
||||||
if octet == 17:
|
|
||||||
octet = 18
|
|
||||||
subnet = f"192.168.{octet}.0/24"
|
|
||||||
gateway = f"192.168.{octet}.1"
|
|
||||||
bundle_ip = f"192.168.{octet}.2"
|
|
||||||
return subnet, gateway, bundle_ip
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Terminal escape-sequence helpers shared across all bottle backends."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shlex
|
|
||||||
|
|
||||||
|
|
||||||
# color name → (normal_idx, normal_hex, bright_idx, bright_hex, dark_bg_hex)
|
|
||||||
# OSC 4 sets indexed palette entries (affects syntax-highlighted code and any
|
|
||||||
# TUI content that uses indexed colors). dark_bg_hex is used for OSC 11
|
|
||||||
# (default background) — a very dark tint that's visible even when the TUI
|
|
||||||
# uses true/24-bit colors for its own chrome, which would otherwise bypass
|
|
||||||
# the palette entirely.
|
|
||||||
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
|
|
||||||
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
|
||||||
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
|
||||||
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
|
||||||
"blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
|
||||||
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
|
|
||||||
_RESET_PRINTF = "printf '\\033]104\\007\\033]111\\007'"
|
|
||||||
|
|
||||||
|
|
||||||
def palette_printf(color: str) -> str:
|
|
||||||
"""Shell `printf` command that emits OSC 4 + OSC 11 to tint the terminal
|
|
||||||
for *color*: sets the normal/bright palette entries AND the default
|
|
||||||
background to a dark shade of that color. Returns '' if unknown."""
|
|
||||||
entry = _COLORS.get(color)
|
|
||||||
if not entry:
|
|
||||||
return ""
|
|
||||||
n_idx, n_hex, b_idx, b_hex, bg_hex = entry
|
|
||||||
seq = (
|
|
||||||
f"\\033]4;{n_idx};{n_hex}\\007"
|
|
||||||
f"\\033]4;{b_idx};{b_hex}\\007"
|
|
||||||
f"\\033]11;{bg_hex}\\007"
|
|
||||||
)
|
|
||||||
return f"printf '{seq}'"
|
|
||||||
|
|
||||||
|
|
||||||
def exec_shell_script(
|
|
||||||
agent_argv: list[str],
|
|
||||||
terminal_title: str = "",
|
|
||||||
terminal_color: str = "",
|
|
||||||
) -> str | None:
|
|
||||||
"""Build a shell script string that optionally sets the terminal
|
|
||||||
title and/or palette before running *agent_argv*, and resets the
|
|
||||||
palette + background on exit. Returns None when no decoration is
|
|
||||||
needed — callers should run *agent_argv* directly in that case."""
|
|
||||||
title_cmd = (
|
|
||||||
f"printf '\\033]0;%s\\007' {shlex.quote(terminal_title)}"
|
|
||||||
if terminal_title else ""
|
|
||||||
)
|
|
||||||
pal_cmd = palette_printf(terminal_color)
|
|
||||||
|
|
||||||
if not title_cmd and not pal_cmd:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
if title_cmd:
|
|
||||||
parts.append(title_cmd)
|
|
||||||
if pal_cmd:
|
|
||||||
parts.append(pal_cmd)
|
|
||||||
parts.append(shlex.join(agent_argv))
|
|
||||||
parts.append(_RESET_PRINTF)
|
|
||||||
else:
|
|
||||||
# No palette change — exec so the agent replaces the shell.
|
|
||||||
parts.append(f"exec {shlex.join(agent_argv)}")
|
|
||||||
|
|
||||||
return "; ".join(parts)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Cross-backend utility helpers — host-side primitives shared by
|
|
||||||
every backend implementation. Backend-specific helpers live one level
|
|
||||||
deeper (e.g. bot_bottle/backend/docker/util.py)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import ssl
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..log import die, info
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..egress import EgressPlan
|
|
||||||
|
|
||||||
|
|
||||||
# Debian-family CA layout, shared by every backend (all guest images
|
|
||||||
# are Debian-family). AGENT_CA_PATH is the source path that
|
|
||||||
# `update-ca-certificates` reads; AGENT_CA_BUNDLE is the bundle it
|
|
||||||
# rebuilds, which curl, Python `ssl`, and OpenSSL-based tools all read
|
|
||||||
# by default.
|
|
||||||
AGENT_CA_PATH = "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt"
|
|
||||||
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
|
||||||
|
|
||||||
|
|
||||||
def host_skill_dir(name: str) -> str:
|
|
||||||
"""Return the host-side path for a named skill:
|
|
||||||
`$HOME/.claude/skills/<name>`. Dies if HOME is unset."""
|
|
||||||
home = os.environ.get("HOME")
|
|
||||||
if not home:
|
|
||||||
die("HOME not set")
|
|
||||||
return f"{home}/.claude/skills/{name}"
|
|
||||||
|
|
||||||
|
|
||||||
def select_ca_cert(egress_plan: EgressPlan) -> tuple[Path, str]:
|
|
||||||
"""Return the egress MITM CA cert path and label for provision_ca.
|
|
||||||
|
|
||||||
Launch always mints the CA and re-binds the host path into the
|
|
||||||
egress_plan before provision runs, so an empty/missing path here
|
|
||||||
means launch's bringup is broken — fatal."""
|
|
||||||
cert = egress_plan.mitmproxy_ca_cert_only_host_path
|
|
||||||
if cert == Path() or not cert.is_file():
|
|
||||||
die(
|
|
||||||
f"egress CA cert missing at {cert or '(empty)'}; "
|
|
||||||
f"launch must have called egress_tls_init and "
|
|
||||||
f"re-bound the plan before provision"
|
|
||||||
)
|
|
||||||
return cert, "egress"
|
|
||||||
|
|
||||||
|
|
||||||
def log_ca_fingerprint(cert_host_path: Path, label: str) -> None:
|
|
||||||
"""Compute the cert's SHA-256 fingerprint over its DER bytes
|
|
||||||
(stdlib `ssl` + `hashlib`) and log it once to stderr — the
|
|
||||||
standard fingerprint form. Only ever touches the public cert;
|
|
||||||
the private key stays on the host under the stage dir until
|
|
||||||
teardown."""
|
|
||||||
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
|
|
||||||
fingerprint = hashlib.sha256(der).hexdigest()
|
|
||||||
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
"""Per-bottle persistent state (PRD 0016).
|
|
||||||
|
|
||||||
Holds the per-bottle Dockerfile override that capability-block
|
|
||||||
remediation writes, the transcript snapshot the state-preservation
|
|
||||||
helper saves before teardown, and the launch metadata that lets
|
|
||||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
|
||||||
lives at:
|
|
||||||
|
|
||||||
~/.bot-bottle/state/<identity>/
|
|
||||||
metadata.json — agent_name + cwd + started_at (for resume)
|
|
||||||
Dockerfile — per-bottle override (absent → use repo's)
|
|
||||||
transcript/ — last snapshotted agent state (best-effort)
|
|
||||||
|
|
||||||
When the per-bottle Dockerfile is present, the launch step builds
|
|
||||||
the agent image with a per-bottle tag (bot-bottle-rebuilt-<id>)
|
|
||||||
from this file rather than the repo's. The build context is still
|
|
||||||
the repo root so the Dockerfile can COPY bot_bottle source files
|
|
||||||
the same way the original does.
|
|
||||||
|
|
||||||
Identity model:
|
|
||||||
- Every `cli.py start <agent>` mints a fresh identity via
|
|
||||||
`bottle_identity(agent_name)`: slug-prefix for readability plus a
|
|
||||||
5-char random suffix for parallel-safe uniqueness. The metadata
|
|
||||||
written at launch time pins (agent_name, cwd) to that identity.
|
|
||||||
- `cli.py resume <identity>` reads the metadata and re-launches a
|
|
||||||
bottle pinned to the same identity, picking up any per-bottle
|
|
||||||
Dockerfile and transcript snapshot.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from . import supervise as _supervise
|
|
||||||
|
|
||||||
|
|
||||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
|
||||||
_STATE_SUBDIR = "state"
|
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
|
||||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
|
||||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
|
||||||
# live here so chunk 3's `docker compose up` can find them at stable
|
|
||||||
# paths. Each sidecar's `prepare()` writes config + CAs into its own
|
|
||||||
# subdir; the launch step is unchanged today (still `docker cp`).
|
|
||||||
_EGRESS_SUBDIR = "egress"
|
|
||||||
_GIT_GATE_SUBDIR = "git-gate"
|
|
||||||
_SUPERVISE_SUBDIR = "supervise"
|
|
||||||
_AGENT_SUBDIR = "agent"
|
|
||||||
_METADATA_NAME = "metadata.json"
|
|
||||||
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
|
||||||
# Host's apply paths keep these files fresh so supervise's
|
|
||||||
# `list-egress-routes` MCP tool returns the current state —
|
|
||||||
# not a snapshot from launch time.
|
|
||||||
_LIVE_CONFIG_SUBDIR = "live-config"
|
|
||||||
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
|
||||||
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
|
||||||
# Empty marker file. capability_apply writes it before teardown so
|
|
||||||
# cli.py's session-end cleanup knows to preserve the state dir for
|
|
||||||
# `cli.py resume <identity>`. Absent = clean up.
|
|
||||||
_PRESERVE_MARKER = ".preserve"
|
|
||||||
|
|
||||||
# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human
|
|
||||||
# operators starting bottles by hand; collision-free in practice.
|
|
||||||
_RANDOM_SUFFIX_LEN = 5
|
|
||||||
_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits
|
|
||||||
|
|
||||||
|
|
||||||
def bottle_identity(agent_name: str) -> str:
|
|
||||||
"""Mint a fresh per-launch bottle identity. The slug-prefix is
|
|
||||||
`slugify(agent_name)` for readability; the suffix is 5 random
|
|
||||||
base36 chars so two simultaneous `start <agent>` invocations
|
|
||||||
don't collide on container/network names.
|
|
||||||
|
|
||||||
Every call produces a different identity (non-deterministic).
|
|
||||||
To continue an existing bottle's state, use the recorded
|
|
||||||
identity from BottleMetadata via `cli.py resume <identity>`,
|
|
||||||
not this function."""
|
|
||||||
from .backend.docker import util as docker_mod
|
|
||||||
slug = docker_mod.slugify(agent_name)
|
|
||||||
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
|
|
||||||
return f"{slug}-{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleMetadata:
|
|
||||||
"""Persistent record of how a bottle was launched, written at
|
|
||||||
start time and read by `cli.py resume`. Lives at
|
|
||||||
~/.bot-bottle/state/<identity>/metadata.json."""
|
|
||||||
|
|
||||||
identity: str
|
|
||||||
agent_name: str
|
|
||||||
cwd: str # empty string when --cwd was not passed
|
|
||||||
copy_cwd: bool
|
|
||||||
started_at: str # ISO 8601 UTC
|
|
||||||
# PRD 0018 chunk 3: derivable from identity via
|
|
||||||
# `compose_project_name(identity)`, but persisted explicitly so
|
|
||||||
# dashboard / cleanup / resume tooling can read it without
|
|
||||||
# importing the compose module. Empty string for state dirs
|
|
||||||
# written before chunk 3 (resume / inspect should fall back to
|
|
||||||
# deriving from identity in that case).
|
|
||||||
compose_project: str = ""
|
|
||||||
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
|
|
||||||
# for state dirs written before PRD 0040; callers default to "docker"
|
|
||||||
# for backward compatibility.
|
|
||||||
backend: str = ""
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def metadata_path(identity: str) -> Path:
|
|
||||||
return bottle_state_dir(identity) / _METADATA_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def write_metadata(metadata: BottleMetadata) -> Path:
|
|
||||||
"""Persist `metadata` to ~/.bot-bottle/state/<identity>/metadata.json.
|
|
||||||
Mode 0o644 — no secrets, just (agent_name, cwd, timestamp)."""
|
|
||||||
path = metadata_path(metadata.identity)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n")
|
|
||||||
path.chmod(0o644)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_metadata(identity: str) -> BottleMetadata | None:
|
|
||||||
"""Return the metadata for `identity`, or None if no state has
|
|
||||||
been recorded for it. Used by `cli.py resume` to reconstruct
|
|
||||||
the launch spec."""
|
|
||||||
path = metadata_path(identity)
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
raw = json.loads(path.read_text())
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
raw_typed = cast(dict[str, object], raw)
|
|
||||||
return BottleMetadata(
|
|
||||||
identity=str(raw_typed.get("identity", identity)),
|
|
||||||
agent_name=str(raw_typed.get("agent_name", "")),
|
|
||||||
cwd=str(raw_typed.get("cwd", "")),
|
|
||||||
copy_cwd=bool(raw_typed.get("copy_cwd", False)),
|
|
||||||
started_at=str(raw_typed.get("started_at", "")),
|
|
||||||
compose_project=str(raw_typed.get("compose_project", "")),
|
|
||||||
backend=str(raw_typed.get("backend", "")),
|
|
||||||
label=str(raw_typed.get("label", "")),
|
|
||||||
color=str(raw_typed.get("color", "")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def bottle_state_dir(identity: str) -> Path:
|
|
||||||
"""Per-bottle state directory on the host. Created lazily by the
|
|
||||||
write helpers; readers tolerate its absence."""
|
|
||||||
return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity
|
|
||||||
|
|
||||||
|
|
||||||
def per_bottle_dockerfile_path(identity: str) -> Path:
|
|
||||||
return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def per_bottle_dockerfile(identity: str) -> str | None:
|
|
||||||
"""Return the per-bottle Dockerfile content if present, else
|
|
||||||
None. None means: use the repo's Dockerfile (the original
|
|
||||||
pre-capability-block behavior)."""
|
|
||||||
p = per_bottle_dockerfile_path(identity)
|
|
||||||
if p.is_file():
|
|
||||||
return p.read_text()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
|
||||||
p = per_bottle_dockerfile_path(identity)
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
p.write_text(content)
|
|
||||||
p.chmod(0o644)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def committed_image_path(identity: str) -> Path:
|
|
||||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
|
||||||
"""Persist the committed image tag for `identity`. The next
|
|
||||||
`cli.py resume <identity>` will boot from this image instead of
|
|
||||||
rebuilding from the Dockerfile."""
|
|
||||||
path = committed_image_path(identity)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(image_tag.strip() + "\n")
|
|
||||||
path.chmod(0o644)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_committed_image(identity: str) -> str | None:
|
|
||||||
"""Return the committed image tag for `identity`, or None if no
|
|
||||||
commit has been recorded. Used by the Docker launch step to skip
|
|
||||||
the Dockerfile build when a committed snapshot exists."""
|
|
||||||
path = committed_image_path(identity)
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
tag = path.read_text().strip()
|
|
||||||
return tag or None
|
|
||||||
|
|
||||||
|
|
||||||
def per_bottle_image_tag(identity: str) -> str:
|
|
||||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
|
||||||
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
|
||||||
the docker image cache."""
|
|
||||||
return f"bot-bottle-rebuilt-{identity}:latest"
|
|
||||||
|
|
||||||
|
|
||||||
def live_config_dir(identity: str) -> Path:
|
|
||||||
"""Per-bottle live-config dir. Bind-mounted read-only into the
|
|
||||||
supervise sidecar; the host's apply paths refresh the files on
|
|
||||||
every operator approval so the agent's `list-*` MCP tools always
|
|
||||||
return current state."""
|
|
||||||
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
def live_routes_path(identity: str) -> Path:
|
|
||||||
return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def live_allowlist_path(identity: str) -> Path:
|
|
||||||
return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def write_live_config(
|
|
||||||
identity: str, *, routes: str = "", allowlist: str = "",
|
|
||||||
) -> Path:
|
|
||||||
"""Initialise (or refresh) the live-config dir. Empty-string args
|
|
||||||
leave the existing file alone (caller passes only what it knows).
|
|
||||||
Returns the live-config dir path."""
|
|
||||||
d = live_config_dir(identity)
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
if routes:
|
|
||||||
p = live_routes_path(identity)
|
|
||||||
p.write_text(routes)
|
|
||||||
p.chmod(0o644)
|
|
||||||
if allowlist:
|
|
||||||
p = live_allowlist_path(identity)
|
|
||||||
p.write_text(allowlist)
|
|
||||||
p.chmod(0o644)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def transcript_snapshot_dir(identity: str) -> Path:
|
|
||||||
"""Where capability_apply stashes the agent's transcript before
|
|
||||||
teardown, so the next `cli.py start <agent>` can offer to
|
|
||||||
resume from it."""
|
|
||||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
|
|
||||||
#
|
|
||||||
# Each sidecar gets its own subdir under the bottle's state dir for
|
|
||||||
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
|
|
||||||
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
|
|
||||||
# them along with everything else when the bottle session ends and
|
|
||||||
# nothing requested preservation.
|
|
||||||
|
|
||||||
|
|
||||||
def egress_state_dir(identity: str) -> Path:
|
|
||||||
"""State subdir for the egress sidecar: routes.yaml + the
|
|
||||||
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
|
|
||||||
return bottle_state_dir(identity) / _EGRESS_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_state_dir(identity: str) -> Path:
|
|
||||||
"""State subdir for the git-gate sidecar: entrypoint + hooks +
|
|
||||||
per-upstream known_hosts. Bind-mount source from chunk 3
|
|
||||||
onward."""
|
|
||||||
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
|
||||||
"""State subdir for the supervise sidecar's current-config dir
|
|
||||||
(bind-mounted into the agent at /etc/bot-bottle/current-config).
|
|
||||||
The queue dir is intentionally NOT under here — it lives at
|
|
||||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
|
||||||
survives state-dir cleanup."""
|
|
||||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
def agent_state_dir(identity: str) -> Path:
|
|
||||||
"""State subdir for the agent's prepare-time scratch files: the
|
|
||||||
env file (docker --env-file source) and the prompt file."""
|
|
||||||
return bottle_state_dir(identity) / _AGENT_SUBDIR
|
|
||||||
|
|
||||||
|
|
||||||
# --- Preserve-on-close marker ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def preserve_marker_path(identity: str) -> Path:
|
|
||||||
return bottle_state_dir(identity) / _PRESERVE_MARKER
|
|
||||||
|
|
||||||
|
|
||||||
def mark_preserved(identity: str) -> Path:
|
|
||||||
"""Mark this bottle's state for preservation across session
|
|
||||||
teardown. Written by capability_apply.apply_capability_change so
|
|
||||||
cli.py's session-end cleanup leaves the state dir intact for a
|
|
||||||
subsequent `cli.py resume`."""
|
|
||||||
path = preserve_marker_path(identity)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.touch()
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def is_preserved(identity: str) -> bool:
|
|
||||||
return preserve_marker_path(identity).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def clear_preserve_marker(identity: str) -> None:
|
|
||||||
"""Idempotent removal. Called at fresh launch (start or resume)
|
|
||||||
so a marker left from a prior capability-block doesn't keep
|
|
||||||
state alive past the next normal session-end."""
|
|
||||||
try:
|
|
||||||
preserve_marker_path(identity).unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_state(identity: str) -> None:
|
|
||||||
"""Remove the per-bottle state dir entirely. Called by cli.py
|
|
||||||
when a bottle session ends and is_preserved(identity) is False.
|
|
||||||
Idempotent — missing dir is success."""
|
|
||||||
import shutil
|
|
||||||
state_dir = bottle_state_dir(identity)
|
|
||||||
if state_dir.is_dir():
|
|
||||||
shutil.rmtree(state_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BottleMetadata",
|
|
||||||
"agent_state_dir",
|
|
||||||
"bottle_identity",
|
|
||||||
"bottle_state_dir",
|
|
||||||
"cleanup_state",
|
|
||||||
"clear_preserve_marker",
|
|
||||||
"committed_image_path",
|
|
||||||
"egress_state_dir",
|
|
||||||
"git_gate_state_dir",
|
|
||||||
"is_preserved",
|
|
||||||
"mark_preserved",
|
|
||||||
"metadata_path",
|
|
||||||
"per_bottle_dockerfile",
|
|
||||||
"per_bottle_dockerfile_path",
|
|
||||||
"per_bottle_image_tag",
|
|
||||||
"preserve_marker_path",
|
|
||||||
"read_committed_image",
|
|
||||||
"read_metadata",
|
|
||||||
"supervise_state_dir",
|
|
||||||
"transcript_snapshot_dir",
|
|
||||||
"write_committed_image",
|
|
||||||
"write_metadata",
|
|
||||||
"write_per_bottle_dockerfile",
|
|
||||||
]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
|
||||||
|
|
||||||
Walks every registered backend (docker + smolmachines) so a single
|
|
||||||
`./cli.py cleanup` reaps both backends' leftovers — orphaned
|
|
||||||
smolvm machines won't survive a docker-only cleanup pass (issue
|
|
||||||
addressed alongside #77).
|
|
||||||
|
|
||||||
Each backend's `prepare_cleanup` enumerates its own resources;
|
|
||||||
docker's `_list_orphan_state_dirs` consults
|
|
||||||
`enumerate_active_agents()` for the union of live identities so
|
|
||||||
state dirs of running smolmachines bottles aren't reaped. State
|
|
||||||
dirs are shared layout, so docker is the single owner of that
|
|
||||||
bucket.
|
|
||||||
|
|
||||||
State dirs with `.preserve` are intentionally never touched — they
|
|
||||||
hold capability-block rebuilds or crash snapshots the operator may
|
|
||||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
|
||||||
is the path for those.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from ..backend import get_bottle_backend, known_backend_names
|
|
||||||
from ..log import info
|
|
||||||
from ._common import read_tty_line
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_cleanup(_argv: list[str]) -> int:
|
|
||||||
# Order: stable backend iteration so the y/N output is
|
|
||||||
# deterministic across runs.
|
|
||||||
plans = [
|
|
||||||
(name, get_bottle_backend(name)) for name in known_backend_names()
|
|
||||||
]
|
|
||||||
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
|
|
||||||
|
|
||||||
if all(p.empty for _, _, p in prepared):
|
|
||||||
info("no bot-bottle resources to clean up")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
for name, _, plan in prepared:
|
|
||||||
if plan.empty:
|
|
||||||
continue
|
|
||||||
info(f"--- {name} backend ---")
|
|
||||||
plan.print()
|
|
||||||
|
|
||||||
if not _prompt_yes("remove all of the above?"):
|
|
||||||
info("cleanup: skipped")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
for name, backend, plan in prepared:
|
|
||||||
if plan.empty:
|
|
||||||
continue
|
|
||||||
backend.cleanup(plan)
|
|
||||||
info("cleanup: done")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_yes(message: str) -> bool:
|
|
||||||
sys.stderr.write(f"bot-bottle: {message} [y/N] ")
|
|
||||||
sys.stderr.flush()
|
|
||||||
reply = read_tty_line()
|
|
||||||
return reply in ("y", "Y", "yes", "YES")
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""commit: freeze a running bottle's state to a resumable artifact.
|
|
||||||
|
|
||||||
Docker bottles are committed to a local Docker image. Macos-container
|
|
||||||
bottles are exported and rebuilt as a local Apple Container image.
|
|
||||||
Smolmachines bottles are packed from the running VM into a
|
|
||||||
`.smolmachine` artifact. The resulting reference is stored in
|
|
||||||
per-bottle state so the next `./cli.py resume <slug>` boots from the
|
|
||||||
snapshot instead of rebuilding from the Dockerfile.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
|
||||||
from ..backend.freeze import CommitCancelled, get_freezer
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
from ..log import die
|
|
||||||
from ._common import PROG
|
|
||||||
from . import tui
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_commit(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
|
||||||
parser.add_argument(
|
|
||||||
"slug",
|
|
||||||
nargs="?",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"bottle slug from `cli.py list active` "
|
|
||||||
"(omit to pick interactively)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
slug = args.slug
|
|
||||||
if slug is None:
|
|
||||||
active = enumerate_active_agents()
|
|
||||||
if not active:
|
|
||||||
die("no active bottles; start one with `./cli.py start`")
|
|
||||||
choices = [a.slug for a in active]
|
|
||||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
|
||||||
if slug is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
backend = metadata.backend if metadata else ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_freezer(backend).commit_slug(slug)
|
|
||||||
except CommitCancelled:
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""doctor: validate host prerequisites for running bot-bottle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ._common import PROG
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(label: str, detail: str) -> None:
|
|
||||||
print(f"ok: {label}: {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def _fail(label: str, detail: str) -> None:
|
|
||||||
print(f"fail: {label}: {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def _check_python() -> bool:
|
|
||||||
version = sys.version_info
|
|
||||||
detail = f"{version.major}.{version.minor}.{version.micro}"
|
|
||||||
if version >= (3, 11):
|
|
||||||
_ok("python", detail)
|
|
||||||
return True
|
|
||||||
_fail("python", f"{detail}; need 3.11 or newer")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _check_docker() -> bool:
|
|
||||||
docker = shutil.which("docker")
|
|
||||||
if not docker:
|
|
||||||
_fail("docker", "docker command not found")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[docker, "info"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
_fail("docker", f"daemon check failed: {exc}")
|
|
||||||
return False
|
|
||||||
if result.returncode == 0:
|
|
||||||
_ok("docker", "daemon reachable")
|
|
||||||
return True
|
|
||||||
_fail("docker", "daemon not reachable")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _check_config_dir() -> bool:
|
|
||||||
config = Path.home() / ".bot-bottle"
|
|
||||||
if config.is_dir():
|
|
||||||
_ok("config", str(config))
|
|
||||||
return True
|
|
||||||
_fail("config", f"{config} does not exist")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_doctor(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} doctor", add_help=True)
|
|
||||||
parser.parse_args(argv)
|
|
||||||
|
|
||||||
checks = (
|
|
||||||
_check_python(),
|
|
||||||
_check_docker(),
|
|
||||||
_check_config_dir(),
|
|
||||||
)
|
|
||||||
return 0 if all(checks) else 1
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""list: list available agents or active bottles."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
|
||||||
from ..manifest import ManifestIndex
|
|
||||||
from ._common import PROG, USER_CWD
|
|
||||||
|
|
||||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
|
||||||
"red": "\033[91m",
|
|
||||||
"green": "\033[92m",
|
|
||||||
"yellow": "\033[93m",
|
|
||||||
"blue": "\033[94m",
|
|
||||||
"magenta": "\033[95m",
|
|
||||||
}
|
|
||||||
_ANSI_RESET = "\033[0m"
|
|
||||||
|
|
||||||
|
|
||||||
def _ansi_label(text: str, color: str) -> str:
|
|
||||||
if not color:
|
|
||||||
return text
|
|
||||||
if not sys.stdout.isatty():
|
|
||||||
return text
|
|
||||||
term = os.environ.get("TERM", "")
|
|
||||||
if term in ("dumb", ""):
|
|
||||||
return text
|
|
||||||
code = _ANSI_COLOR_CODES.get(color)
|
|
||||||
if not code:
|
|
||||||
return text
|
|
||||||
return f"{code}{text}{_ANSI_RESET}"
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
|
|
||||||
parser.add_argument("scope", choices=["available", "active"])
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
if args.scope == "available":
|
|
||||||
manifest = ManifestIndex.resolve(USER_CWD)
|
|
||||||
for name in manifest.all_agent_names:
|
|
||||||
print(name)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# `active` enumerates every backend (docker + smolmachines)
|
|
||||||
# so smolmachines bottles aren't hidden behind the env var.
|
|
||||||
active = enumerate_active_agents()
|
|
||||||
if not active:
|
|
||||||
print("no active bot-bottle bottles", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
# One line per bottle: `<backend>\t<slug>\t<label>\t<services>`.
|
|
||||||
# Tab-separated keeps the format stable for shell pipelines.
|
|
||||||
for b in active:
|
|
||||||
services = ",".join(b.services) if b.services else "-"
|
|
||||||
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name
|
|
||||||
colored_name = _ansi_label(display_name, b.color)
|
|
||||||
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
|
|
||||||
return 0
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""resume: re-launch a bottle by its identity.
|
|
||||||
|
|
||||||
Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
|
||||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
|
||||||
then runs the same launch core as `start` — but pinned to the
|
|
||||||
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
|
||||||
(from capability-block apply) and transcript snapshot under the same
|
|
||||||
state dir.
|
|
||||||
|
|
||||||
Use case: an agent calls capability-block, the dashboard approves
|
|
||||||
and tears down the bottle, the operator runs
|
|
||||||
./cli.py resume <identity>
|
|
||||||
to bring up the replacement with the new capabilities baked in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from ..backend import BottleSpec
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
from ..log import die
|
|
||||||
from ..manifest import ManifestIndex
|
|
||||||
from ._common import PROG, USER_CWD
|
|
||||||
from .start import _launch_bottle
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_resume(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
|
||||||
parser.add_argument("--remote-control", action="store_true")
|
|
||||||
parser.add_argument(
|
|
||||||
"identity",
|
|
||||||
help="bottle identity from a prior `start` (see its session-end output)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
metadata = read_metadata(args.identity)
|
|
||||||
if metadata is None:
|
|
||||||
die(
|
|
||||||
f"no state recorded for identity {args.identity!r}; "
|
|
||||||
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = ManifestIndex.resolve(USER_CWD)
|
|
||||||
manifest.require_agent(metadata.agent_name)
|
|
||||||
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=manifest,
|
|
||||||
agent_name=metadata.agent_name,
|
|
||||||
copy_cwd=metadata.copy_cwd,
|
|
||||||
user_cwd=metadata.cwd or USER_CWD,
|
|
||||||
identity=metadata.identity,
|
|
||||||
)
|
|
||||||
backend_name = metadata.backend or None
|
|
||||||
return _launch_bottle(
|
|
||||||
spec,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
remote_control=args.remote_control,
|
|
||||||
backend_name=backend_name,
|
|
||||||
)
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""start: boot a sandboxed container for a named agent and attach an
|
|
||||||
interactive claude-code session. The container is torn down when the
|
|
||||||
session ends.
|
|
||||||
|
|
||||||
The launch core is shared with `cli.py resume <identity>` through
|
|
||||||
the private orchestrator `_launch_bottle`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from ..agent_provider import runtime_for
|
|
||||||
from ..backend import (
|
|
||||||
Bottle,
|
|
||||||
BottleSpec,
|
|
||||||
enumerate_active_agents,
|
|
||||||
get_bottle_backend,
|
|
||||||
known_backend_names,
|
|
||||||
)
|
|
||||||
from ..backend.docker import util as docker_mod
|
|
||||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
|
||||||
from ..bottle_state import (
|
|
||||||
cleanup_state,
|
|
||||||
is_preserved,
|
|
||||||
mark_preserved,
|
|
||||||
)
|
|
||||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
|
||||||
from ..log import info
|
|
||||||
from ..manifest import ManifestIndex
|
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
|
||||||
from . import tui
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
|
||||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
|
||||||
parser.add_argument("--remote-control", action="store_true")
|
|
||||||
parser.add_argument(
|
|
||||||
"--backend",
|
|
||||||
choices=known_backend_names(),
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
|
||||||
"or host auto-selection). Overrides the env var when set."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"name",
|
|
||||||
nargs="?",
|
|
||||||
default=None,
|
|
||||||
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
|
||||||
|
|
||||||
manifest = ManifestIndex.resolve(USER_CWD)
|
|
||||||
|
|
||||||
agent_name: str | None = args.name
|
|
||||||
if agent_name is None:
|
|
||||||
agent_name = tui.filter_select(
|
|
||||||
manifest.all_agent_names,
|
|
||||||
title="Select agent",
|
|
||||||
)
|
|
||||||
if agent_name is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
backend_name: str | None = args.backend
|
|
||||||
|
|
||||||
label, color = tui.name_color_modal(default_label=agent_name)
|
|
||||||
label, color = _resolve_unique_label(label, color)
|
|
||||||
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=manifest,
|
|
||||||
agent_name=agent_name,
|
|
||||||
copy_cwd=args.cwd,
|
|
||||||
user_cwd=USER_CWD,
|
|
||||||
label=label,
|
|
||||||
color=color,
|
|
||||||
)
|
|
||||||
return _launch_bottle(
|
|
||||||
spec,
|
|
||||||
dry_run=dry_run,
|
|
||||||
remote_control=args.remote_control,
|
|
||||||
backend_name=backend_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Launch helpers ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_with_preflight(
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
stage_dir: Path,
|
|
||||||
render_preflight: Callable[[DockerBottlePlan], None],
|
|
||||||
prompt_yes: Callable[[], bool],
|
|
||||||
dry_run: bool = False,
|
|
||||||
backend_name: str | None = None,
|
|
||||||
) -> tuple[DockerBottlePlan | None, str]:
|
|
||||||
"""Run `backend.prepare`, render the preflight summary via the
|
|
||||||
injected callable, prompt y/N via the injected callable.
|
|
||||||
|
|
||||||
`backend_name` selects which backend prepares the plan
|
|
||||||
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
|
|
||||||
passes whatever `--backend` resolved to.
|
|
||||||
|
|
||||||
Returns `(plan, identity)`. `plan` is None on dry-run or
|
|
||||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
|
||||||
returns so callers can reap the prepare-time state dir via
|
|
||||||
`settle_state(identity)` in their finally — exactly the existing
|
|
||||||
semantics."""
|
|
||||||
backend = get_bottle_backend(backend_name)
|
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
|
||||||
identity = _identity_from_plan(plan)
|
|
||||||
|
|
||||||
render_preflight(plan)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
info("dry-run requested; not starting container.")
|
|
||||||
return None, identity
|
|
||||||
if not prompt_yes():
|
|
||||||
info("aborted by user")
|
|
||||||
return None, identity
|
|
||||||
return plan, identity
|
|
||||||
|
|
||||||
|
|
||||||
def attach_agent(
|
|
||||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
startup_args: tuple[str, ...] = (),
|
|
||||||
) -> int:
|
|
||||||
"""Run the selected provider CLI inside `bottle` as an
|
|
||||||
interactive session. Blocks until the session ends; returns the
|
|
||||||
agent process's exit code.
|
|
||||||
|
|
||||||
`resume=True` adds `--continue` so claude picks up its most
|
|
||||||
recent session non-interactively (no session-picker prompt).
|
|
||||||
First-attach paths (`./cli.py start`) leave it False.
|
|
||||||
|
|
||||||
Used as the inner step of `./cli.py start`."""
|
|
||||||
runtime = runtime_for(agent_provider_template)
|
|
||||||
info(
|
|
||||||
f"attaching interactive {agent_provider_template} session "
|
|
||||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
|
||||||
)
|
|
||||||
agent_args = list(runtime.bypass_args)
|
|
||||||
if remote_control:
|
|
||||||
agent_args.extend(runtime.remote_control_args)
|
|
||||||
agent_args.extend(startup_args)
|
|
||||||
if resume:
|
|
||||||
agent_args.extend(runtime.resume_args)
|
|
||||||
return bottle.exec_agent(agent_args, tty=True)
|
|
||||||
|
|
||||||
|
|
||||||
def capture_claude_session_state(identity: str, exit_code: int) -> None:
|
|
||||||
"""Inside the launch context, while the container is still
|
|
||||||
alive: snapshot the transcript and mark for preservation if
|
|
||||||
claude crashed."""
|
|
||||||
# FIXME: this captures Claude-specific session state. A follow-up
|
|
||||||
# spike should explore freezing provider-neutral container state
|
|
||||||
# instead of relying on each agent's transcript layout.
|
|
||||||
if not identity:
|
|
||||||
return
|
|
||||||
# snapshot_transcript(identity)
|
|
||||||
if exit_code != 0:
|
|
||||||
mark_preserved(identity)
|
|
||||||
|
|
||||||
|
|
||||||
def settle_state(identity: str) -> None:
|
|
||||||
"""Post-teardown housekeeping: print the resume hint if the
|
|
||||||
state was preserved, otherwise reap the per-bottle state dir."""
|
|
||||||
if not identity:
|
|
||||||
return
|
|
||||||
if is_preserved(identity):
|
|
||||||
info(f"to resume this bottle: ./cli.py resume {identity}")
|
|
||||||
return
|
|
||||||
cleanup_state(identity)
|
|
||||||
|
|
||||||
|
|
||||||
def _identity_from_plan(plan: object) -> str:
|
|
||||||
"""Backend-specific: the docker plan exposes the identity as
|
|
||||||
`.slug`. Other backends in the future would expose their own
|
|
||||||
identity attribute; for now we duck-type to keep this layer
|
|
||||||
backend-agnostic."""
|
|
||||||
return getattr(plan, "slug", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
|
||||||
"""Re-prompt with a disclaimer until the label's slug is not already
|
|
||||||
in use among running bottles. Passes through unchanged when no
|
|
||||||
collision is found on the first check."""
|
|
||||||
while True:
|
|
||||||
slug_candidate = docker_mod.slugify(label)
|
|
||||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
|
||||||
if slug_candidate not in active_slugs:
|
|
||||||
return label, color
|
|
||||||
label, color = tui.name_color_modal(
|
|
||||||
default_label=label,
|
|
||||||
disclaimer=f'"{label}" is already in use',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _text_prompt_yes() -> bool:
|
|
||||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
|
||||||
controlling tty via stderr prompt + tty-line read."""
|
|
||||||
sys.stderr.write("bot-bottle: launch this agent? [y/N] ")
|
|
||||||
sys.stderr.flush()
|
|
||||||
reply = read_tty_line()
|
|
||||||
return reply in ("y", "Y", "yes", "YES")
|
|
||||||
|
|
||||||
|
|
||||||
def _text_render_preflight(*, remote_control: bool):
|
|
||||||
def _render(plan: DockerBottlePlan) -> None:
|
|
||||||
plan.print(remote_control=remote_control)
|
|
||||||
return _render
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_bottle(
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
dry_run: bool,
|
|
||||||
remote_control: bool,
|
|
||||||
backend_name: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
|
||||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
|
||||||
attaches claude, and prints the resume hint on session end."""
|
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
|
||||||
identity = ""
|
|
||||||
try:
|
|
||||||
plan, identity = prepare_with_preflight(
|
|
||||||
spec,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
render_preflight=_text_render_preflight(remote_control=remote_control),
|
|
||||||
prompt_yes=_text_prompt_yes,
|
|
||||||
dry_run=dry_run,
|
|
||||||
backend_name=backend_name,
|
|
||||||
)
|
|
||||||
if plan is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
backend = get_bottle_backend(backend_name)
|
|
||||||
with backend.launch(plan) as bottle:
|
|
||||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
|
||||||
exit_code = attach_agent(
|
|
||||||
bottle,
|
|
||||||
remote_control=remote_control,
|
|
||||||
agent_provider_template=agent_provider_template,
|
|
||||||
startup_args=plan.agent_provision.startup_args,
|
|
||||||
)
|
|
||||||
info(
|
|
||||||
f"session ended (exit {exit_code}); "
|
|
||||||
f"container {bottle.name} will be removed"
|
|
||||||
)
|
|
||||||
# While the container is still alive: always snapshot the
|
|
||||||
# transcript and — if the agent exited non-zero — mark
|
|
||||||
# the state for preservation. Capability-block already
|
|
||||||
# did both before triggering teardown from the dashboard;
|
|
||||||
# this picks up crashes / Ctrl-Cs / OOM kills the same
|
|
||||||
# way. snapshot_transcript is best-effort so the
|
|
||||||
# capability-block path's prior snapshot isn't clobbered
|
|
||||||
# when the container is already gone.
|
|
||||||
if agent_provider_template == "claude":
|
|
||||||
capture_claude_session_state(identity, exit_code)
|
|
||||||
return 0
|
|
||||||
finally:
|
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
|
||||||
# sources under state/<slug>/. If we never reached the
|
|
||||||
# launch context (dry-run, preflight-N, prepare exception), or
|
|
||||||
# we did but nothing requested preservation, reap them along
|
|
||||||
# with everything else. `settle_state` subsumes the prior
|
|
||||||
# post-launch settlement and the new pre-launch cleanup.
|
|
||||||
settle_state(identity)
|
|
||||||
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
"""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 handler wires to PRD 0016 (capability-block), which rebuilds
|
|
||||||
the bottle Dockerfile. Egress proposals are queued for operator review
|
|
||||||
as full routes.yaml updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 ..bottle_state import read_metadata
|
|
||||||
# from ..backend.docker.capability_apply import (
|
|
||||||
# CapabilityApplyError,
|
|
||||||
# apply_capability_change,
|
|
||||||
# )
|
|
||||||
from ..backend.docker.egress_apply import (
|
|
||||||
EgressApplyError,
|
|
||||||
applicator as _docker_applicator,
|
|
||||||
)
|
|
||||||
from ..backend.macos_container.egress_apply import (
|
|
||||||
applicator as _macos_applicator,
|
|
||||||
)
|
|
||||||
from ..backend.smolmachines.egress_apply import (
|
|
||||||
applicator as _smolmachines_applicator,
|
|
||||||
)
|
|
||||||
from ..log import Die, error, info
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityApplyError(RuntimeError):
|
|
||||||
"""Placeholder while capability_apply is disabled."""
|
|
||||||
|
|
||||||
from ..supervise import (
|
|
||||||
COMPONENT_FOR_TOOL,
|
|
||||||
AuditEntry,
|
|
||||||
Proposal,
|
|
||||||
Response,
|
|
||||||
STATUS_APPROVED,
|
|
||||||
STATUS_MODIFIED,
|
|
||||||
STATUS_REJECTED,
|
|
||||||
TOOL_CAPABILITY_BLOCK,
|
|
||||||
TOOL_ALLOW,
|
|
||||||
TOOL_EGRESS_BLOCK,
|
|
||||||
TOOL_GITLEAKS_ALLOW,
|
|
||||||
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 = (CapabilityApplyError, EgressApplyError)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
|
||||||
meta = read_metadata(slug)
|
|
||||||
backend = meta.backend if meta is not None else ""
|
|
||||||
if backend == "macos-container":
|
|
||||||
return _macos_applicator.apply_routes_change(slug, content)
|
|
||||||
if backend == "smolmachines":
|
|
||||||
return _smolmachines_applicator.apply_routes_change(slug, content)
|
|
||||||
return _docker_applicator.apply_routes_change(slug, content)
|
|
||||||
|
|
||||||
|
|
||||||
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}]"
|
|
||||||
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
|
|
||||||
|
|
||||||
|
|
||||||
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 file:", 0),
|
|
||||||
])
|
|
||||||
out.extend((line, 0) for line in p.proposed_file.splitlines() or [""])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _suffix_for_tool(tool: str) -> str:
|
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
|
||||||
return ".dockerfile"
|
|
||||||
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
|
||||||
return ".yaml"
|
|
||||||
if tool == TOOL_GITLEAKS_ALLOW:
|
|
||||||
return ".txt"
|
|
||||||
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_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,
|
|
||||||
# )
|
|
||||||
if qp.proposal.tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
|
||||||
diff_before, diff_after = apply_routes_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 _approve_from_tui(
|
|
||||||
stdscr: "curses._CursesWindow", # type: ignore
|
|
||||||
qp: QueuedProposal,
|
|
||||||
*,
|
|
||||||
final_file: str | None = None,
|
|
||||||
notes: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""Approve from curses, prompting for any tool-specific audit note."""
|
|
||||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None:
|
|
||||||
notes = _prompt(stdscr, "allow reason (test fixture/false positive): ")
|
|
||||||
if not notes:
|
|
||||||
return "approve aborted (empty reason)"
|
|
||||||
approve(qp, final_file=final_file, notes=notes)
|
|
||||||
verb = "modified+approved" if final_file is not None else "approved"
|
|
||||||
return _approval_status(qp, verb)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_audit(
|
|
||||||
qp: QueuedProposal,
|
|
||||||
*,
|
|
||||||
action: str,
|
|
||||||
notes: str,
|
|
||||||
diff_before: str,
|
|
||||||
diff_after: str,
|
|
||||||
) -> None:
|
|
||||||
"""Audit log for egress tool."""
|
|
||||||
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:
|
|
||||||
status_line = _approve_from_tui(stdscr, qp)
|
|
||||||
except ApplyError as e:
|
|
||||||
status_line = f"apply failed: {e}"
|
|
||||||
elif key == ord("m"):
|
|
||||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
|
||||||
status_line = "modify unavailable for gitleaks-allow"
|
|
||||||
continue
|
|
||||||
edited = _modify(stdscr, qp)
|
|
||||||
if edited is None:
|
|
||||||
status_line = "modify aborted (no change)"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
status_line = _approve_from_tui(
|
|
||||||
stdscr, qp, final_file=edited,
|
|
||||||
notes="operator modified before approving",
|
|
||||||
)
|
|
||||||
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]}"
|
|
||||||
)
|
|
||||||
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_from_tui(stdscr, qp)
|
|
||||||
except ApplyError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
elif key == ord("m"):
|
|
||||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
|
||||||
return
|
|
||||||
edited = _modify(stdscr, qp)
|
|
||||||
if edited is not None:
|
|
||||||
try:
|
|
||||||
_approve_from_tui(
|
|
||||||
stdscr, 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",
|
|
||||||
]
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
"""tui.py — minimal curses filter-select picker for CLI prompts.
|
|
||||||
|
|
||||||
Exposed surface:
|
|
||||||
|
|
||||||
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
|
|
||||||
name_color_modal(default_label, *, tty_path="/dev/tty") -> (str, str)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# name_color_modal — two-step label + color picker
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_ANSI_COLORS = [
|
|
||||||
"red", "green", "yellow", "blue", "magenta",
|
|
||||||
]
|
|
||||||
|
|
||||||
_CURSES_COLOR_MAP: dict[str, int] = {
|
|
||||||
"red": curses.COLOR_RED,
|
|
||||||
"green": curses.COLOR_GREEN,
|
|
||||||
"yellow": curses.COLOR_YELLOW,
|
|
||||||
"blue": curses.COLOR_BLUE,
|
|
||||||
"magenta": curses.COLOR_MAGENTA,
|
|
||||||
}
|
|
||||||
|
|
||||||
_COLOR_NONE = "(none)"
|
|
||||||
|
|
||||||
|
|
||||||
def name_color_modal(
|
|
||||||
default_label: str,
|
|
||||||
*,
|
|
||||||
disclaimer: str = "",
|
|
||||||
tty_path: str = "/dev/tty",
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Present a two-step curses modal: first edit the agent label,
|
|
||||||
then optionally pick a color.
|
|
||||||
|
|
||||||
``disclaimer`` is shown below the input field — use it to surface
|
|
||||||
an error from a previous attempt (e.g. name already in use).
|
|
||||||
|
|
||||||
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
|
||||||
color name strings or ``""`` for no color. Falls back to
|
|
||||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
|
|
||||||
except OSError:
|
|
||||||
return default_label, ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
fd_dup = os.dup(tty_fd.fileno())
|
|
||||||
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
||||||
return default_label, ""
|
|
||||||
finally:
|
|
||||||
tty_fd.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
|
|
||||||
import io
|
|
||||||
orig_stdin = sys.__stdin__
|
|
||||||
orig_stdout = sys.__stdout__
|
|
||||||
try:
|
|
||||||
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]
|
|
||||||
os.environ.setdefault("TERM", "xterm-256color")
|
|
||||||
|
|
||||||
screen = curses.initscr()
|
|
||||||
curses.noecho()
|
|
||||||
curses.cbreak()
|
|
||||||
screen.keypad(True)
|
|
||||||
try:
|
|
||||||
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
|
||||||
color = _color_step(screen, label)
|
|
||||||
finally:
|
|
||||||
screen.keypad(False)
|
|
||||||
curses.nocbreak()
|
|
||||||
curses.echo()
|
|
||||||
curses.endwin()
|
|
||||||
finally:
|
|
||||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
|
||||||
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
|
||||||
return label, color
|
|
||||||
|
|
||||||
|
|
||||||
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
|
||||||
"""Step 1: edit the label. First printable key replaces the
|
|
||||||
pre-fill; subsequent keys append. Enter confirms."""
|
|
||||||
text = default_label
|
|
||||||
replaced = False # True once the user has typed their first char
|
|
||||||
|
|
||||||
while True:
|
|
||||||
_render_label(screen, text, disclaimer=disclaimer)
|
|
||||||
try:
|
|
||||||
key = screen.getch()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return default_label
|
|
||||||
|
|
||||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
|
||||||
return text.strip() or default_label
|
|
||||||
|
|
||||||
if key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
|
|
||||||
if replaced:
|
|
||||||
text = text[:-1]
|
|
||||||
else:
|
|
||||||
text = ""
|
|
||||||
replaced = True
|
|
||||||
|
|
||||||
elif 32 <= key <= 126:
|
|
||||||
if not replaced:
|
|
||||||
text = chr(key)
|
|
||||||
replaced = True
|
|
||||||
else:
|
|
||||||
text += chr(key)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
|
||||||
screen.erase()
|
|
||||||
rows, cols = screen.getmaxyx()
|
|
||||||
sep = "─" * min(cols - 1, 40)
|
|
||||||
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
|
|
||||||
_addstr_safe(screen, 1, 0, sep)
|
|
||||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
|
||||||
_addstr_safe(screen, 3, 0, sep)
|
|
||||||
row = 4
|
|
||||||
if disclaimer and rows > row + 1:
|
|
||||||
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
|
|
||||||
row += 1
|
|
||||||
if rows > row + 1:
|
|
||||||
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
|
||||||
screen.refresh()
|
|
||||||
|
|
||||||
|
|
||||||
def _color_step(screen: Any, confirmed_label: str) -> str:
|
|
||||||
"""Step 2: pick a color from the list, or skip."""
|
|
||||||
items = [_COLOR_NONE] + _ANSI_COLORS
|
|
||||||
cursor = 0
|
|
||||||
|
|
||||||
# Initialise color pairs once; index 0 = none, 1..16 = palette.
|
|
||||||
color_attrs = _init_color_pairs()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
_render_color(screen, items, cursor, confirmed_label, color_attrs)
|
|
||||||
try:
|
|
||||||
key = screen.getch()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if key in (ord("q"), _KEY_ESC):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
|
||||||
chosen = items[cursor]
|
|
||||||
return "" if chosen == _COLOR_NONE else chosen
|
|
||||||
|
|
||||||
if key in (curses.KEY_UP, ord("k")) and cursor > 0:
|
|
||||||
cursor -= 1
|
|
||||||
elif key in (curses.KEY_DOWN, ord("j")) and cursor < len(items) - 1:
|
|
||||||
cursor += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _init_color_pairs() -> dict[str, int]:
|
|
||||||
"""Return {color_name: curses_attr} for the palette items."""
|
|
||||||
attrs: dict[str, int] = {_COLOR_NONE: curses.A_NORMAL}
|
|
||||||
try:
|
|
||||||
curses.start_color()
|
|
||||||
curses.use_default_colors()
|
|
||||||
pair_idx = 2 # pair 1 reserved for other uses
|
|
||||||
for name in _ANSI_COLORS:
|
|
||||||
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
|
|
||||||
try:
|
|
||||||
curses.init_pair(pair_idx, fg, -1)
|
|
||||||
attr = curses.color_pair(pair_idx) | curses.A_BOLD
|
|
||||||
attrs[name] = attr
|
|
||||||
pair_idx += 1
|
|
||||||
except curses.error:
|
|
||||||
attrs[name] = curses.A_NORMAL
|
|
||||||
except curses.error:
|
|
||||||
for name in _ANSI_COLORS:
|
|
||||||
attrs[name] = curses.A_NORMAL
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
def _render_color(
|
|
||||||
screen: Any,
|
|
||||||
items: list[str],
|
|
||||||
cursor: int,
|
|
||||||
confirmed_label: str,
|
|
||||||
color_attrs: dict[str, int],
|
|
||||||
) -> None:
|
|
||||||
screen.erase()
|
|
||||||
rows, cols = screen.getmaxyx()
|
|
||||||
sep = "─" * min(cols - 1, 40)
|
|
||||||
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
|
|
||||||
_addstr_safe(screen, 1, 0, sep)
|
|
||||||
_addstr_safe(screen, 2, 0, confirmed_label[:cols - 1])
|
|
||||||
_addstr_safe(screen, 3, 0, sep)
|
|
||||||
_addstr_safe(screen, 4, 0, "Color (optional)", curses.A_BOLD)
|
|
||||||
|
|
||||||
list_start = 5
|
|
||||||
list_rows = rows - list_start - 2
|
|
||||||
scroll = max(0, cursor - list_rows + 1)
|
|
||||||
visible = items[scroll: scroll + list_rows]
|
|
||||||
|
|
||||||
for idx, name in enumerate(visible):
|
|
||||||
abs_idx = scroll + idx
|
|
||||||
row = list_start + idx
|
|
||||||
if row >= rows - 2:
|
|
||||||
break
|
|
||||||
prefix = "> " if abs_idx == cursor else " "
|
|
||||||
attr = color_attrs.get(name, curses.A_NORMAL)
|
|
||||||
if abs_idx == cursor:
|
|
||||||
attr |= curses.A_REVERSE
|
|
||||||
_addstr_safe(screen, row, 0, (prefix + name)[:cols - 1], attr)
|
|
||||||
|
|
||||||
_addstr_safe(screen, rows - 2, 0, sep)
|
|
||||||
_addstr_safe(
|
|
||||||
screen, rows - 1, 0,
|
|
||||||
"[↑↓/jk] move [Enter] select [Esc/q] skip",
|
|
||||||
curses.A_DIM,
|
|
||||||
)
|
|
||||||
screen.refresh()
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""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,
|
|
||||||
AgentProvisionDir,
|
|
||||||
AgentProvisionFile,
|
|
||||||
AgentProvisionPlan,
|
|
||||||
)
|
|
||||||
from ...backend.docker import util as docker_mod
|
|
||||||
from ...egress import EgressRoute
|
|
||||||
from ...log import die, info, warn
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ...backend import Bottle, BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_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"
|
|
||||||
|
|
||||||
|
|
||||||
_STATUS_LINE_COLORS = {
|
|
||||||
"red": "\033[91m",
|
|
||||||
"green": "\033[92m",
|
|
||||||
"yellow": "\033[93m",
|
|
||||||
"blue": "\033[94m",
|
|
||||||
"magenta": "\033[95m",
|
|
||||||
}
|
|
||||||
|
|
||||||
_CLAUDE_THEME_COLORS = {
|
|
||||||
"red": "redBright",
|
|
||||||
"green": "greenBright",
|
|
||||||
"yellow": "yellowBright",
|
|
||||||
"blue": "blueBright",
|
|
||||||
"magenta": "magentaBright",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _status_line_script(label: str, color: str) -> str:
|
|
||||||
if not label:
|
|
||||||
return "#!/bin/sh\nprintf '\\n'\n"
|
|
||||||
label_q = shlex.quote(label)
|
|
||||||
if color and color in _STATUS_LINE_COLORS:
|
|
||||||
return (
|
|
||||||
"#!/bin/sh\n"
|
|
||||||
f"printf '%b%s%b\\n' '{_STATUS_LINE_COLORS[color]}' {label_q} '\\033[0m'\n"
|
|
||||||
)
|
|
||||||
return f"#!/bin/sh\nprintf '%s\\n' {label_q}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_theme_payload(color: str) -> dict[str, object] | None:
|
|
||||||
theme_color = _CLAUDE_THEME_COLORS.get(color)
|
|
||||||
if not theme_color:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"name": f"Bot-bottle {color}",
|
|
||||||
"base": "dark",
|
|
||||||
"overrides": {
|
|
||||||
"claude": f"ansi:{theme_color}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_RUNTIME = AgentProviderRuntime(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
image="bot-bottle-claude:latest",
|
|
||||||
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,
|
|
||||||
instance_name: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
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 = "",
|
|
||||||
label: str = "",
|
|
||||||
color: str = "",
|
|
||||||
provider_settings: dict[str, object] | None = None,
|
|
||||||
) -> AgentProvisionPlan:
|
|
||||||
del forward_host_credentials, host_env, provider_settings
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
|
||||||
guest_home = self.guest_home
|
|
||||||
trusted_path = trusted_project_path or guest_home
|
|
||||||
|
|
||||||
env_vars: dict[str, str] = {
|
|
||||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
||||||
"DISABLE_ERROR_REPORTING": "1",
|
|
||||||
}
|
|
||||||
dirs = (
|
|
||||||
AgentProvisionDir(f"{guest_home}/.claude"),
|
|
||||||
AgentProvisionDir(f"{guest_home}/.claude/themes"),
|
|
||||||
)
|
|
||||||
claude_config = state_dir / "claude.json"
|
|
||||||
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
|
||||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"hasCompletedOnboarding": True,
|
|
||||||
"theme": "dark",
|
|
||||||
"bypassPermissionsModeAccepted": True,
|
|
||||||
"projects": claude_projects,
|
|
||||||
}
|
|
||||||
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
|
||||||
claude_config.chmod(0o600)
|
|
||||||
files = [
|
|
||||||
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
|
|
||||||
]
|
|
||||||
|
|
||||||
claude_settings = state_dir / "claude-settings.json"
|
|
||||||
claude_settings_payload: dict[str, object] = {}
|
|
||||||
if label or color:
|
|
||||||
statusline_script = state_dir / "claude-statusline.sh"
|
|
||||||
statusline_script.write_text(_status_line_script(label, color))
|
|
||||||
statusline_script.chmod(0o755)
|
|
||||||
files.append(AgentProvisionFile(
|
|
||||||
statusline_script,
|
|
||||||
f"{guest_home}/.claude/statusline.sh",
|
|
||||||
mode="755",
|
|
||||||
))
|
|
||||||
claude_settings_payload["statusLine"] = {
|
|
||||||
"type": "command",
|
|
||||||
"command": "~/.claude/statusline.sh",
|
|
||||||
}
|
|
||||||
theme_payload = _custom_theme_payload(color)
|
|
||||||
if theme_payload is not None:
|
|
||||||
theme_name = f"bot-bottle-{docker_mod.slugify(label or color)}"
|
|
||||||
theme_file = state_dir / f"{theme_name}.json"
|
|
||||||
theme_file.write_text(json.dumps(theme_payload, indent=2) + "\n")
|
|
||||||
theme_file.chmod(0o644)
|
|
||||||
files.append(AgentProvisionFile(
|
|
||||||
theme_file,
|
|
||||||
f"{guest_home}/.claude/themes/{theme_name}.json",
|
|
||||||
))
|
|
||||||
claude_settings_payload["theme"] = f"custom:{theme_name}"
|
|
||||||
if claude_settings_payload:
|
|
||||||
claude_settings.write_text(json.dumps(claude_settings_payload, indent=2) + "\n")
|
|
||||||
claude_settings.chmod(0o600)
|
|
||||||
files.append(AgentProvisionFile(
|
|
||||||
claude_settings,
|
|
||||||
f"{guest_home}/.claude/settings.json",
|
|
||||||
))
|
|
||||||
egress_routes = (EgressRoute(
|
|
||||||
host="api.anthropic.com",
|
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
|
||||||
token_ref=auth_token,
|
|
||||||
),)
|
|
||||||
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"})
|
|
||||||
|
|
||||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
|
||||||
return AgentProvisionPlan(
|
|
||||||
template=_RUNTIME.template,
|
|
||||||
command=_RUNTIME.command,
|
|
||||||
prompt_mode=_RUNTIME.prompt_mode,
|
|
||||||
image=_RUNTIME.image,
|
|
||||||
dockerfile=dockerfile,
|
|
||||||
guest_home=guest_home,
|
|
||||||
instance_name=instance_name,
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
env_vars=env_vars,
|
|
||||||
guest_env=resolved_guest_env,
|
|
||||||
has_prompt=has_prompt,
|
|
||||||
dirs=dirs,
|
|
||||||
files=tuple(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.manifest.agent
|
|
||||||
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.manifest.agent
|
|
||||||
return prompt_path if plan.agent_provision.has_prompt or 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}")
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# bot-bottle Codex provider image.
|
|
||||||
#
|
|
||||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
|
||||||
# non-root node user, and the provider CLI installed globally.
|
|
||||||
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git ca-certificates curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by codex itself
|
|
||||||
# (codex is a Node CLI), but is convenient for the agent to shell
|
|
||||||
# out to for ad-hoc scripts. Kept on its own layer so it can be
|
|
||||||
# moved to a downstream image if the base ever needs to shrink.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN npm install -g --no-fund --no-audit @openai/codex@0.136.0 \
|
|
||||||
&& npm cache clean --force
|
|
||||||
|
|
||||||
USER node
|
|
||||||
WORKDIR /home/node
|
|
||||||
|
|
||||||
RUN mkdir -p /home/node/.codex
|
|
||||||
|
|
||||||
CMD ["codex"]
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""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,
|
|
||||||
AgentProvisionDir,
|
|
||||||
AgentProvisionCommand,
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
_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",
|
|
||||||
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,
|
|
||||||
instance_name: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
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 = "",
|
|
||||||
label: str = "",
|
|
||||||
color: str = "",
|
|
||||||
provider_settings: dict[str, object] | None = None,
|
|
||||||
) -> AgentProvisionPlan:
|
|
||||||
del auth_token, label, color, provider_settings
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
|
||||||
guest_home = self.guest_home
|
|
||||||
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'
|
|
||||||
"\n"
|
|
||||||
"[tui]\n"
|
|
||||||
'status_line = ["model-with-reasoning"]\n'
|
|
||||||
'terminal_title = ["spinner", "project"]\n'
|
|
||||||
'theme = "ansi"\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 "",
|
|
||||||
))
|
|
||||||
|
|
||||||
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"
|
|
||||||
)))
|
|
||||||
|
|
||||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
|
||||||
return AgentProvisionPlan(
|
|
||||||
template=_RUNTIME.template,
|
|
||||||
command=_RUNTIME.command,
|
|
||||||
prompt_mode=_RUNTIME.prompt_mode,
|
|
||||||
image=_RUNTIME.image,
|
|
||||||
dockerfile=dockerfile,
|
|
||||||
guest_home=guest_home,
|
|
||||||
instance_name=instance_name,
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
env_vars=env_vars,
|
|
||||||
guest_env=resolved_guest_env,
|
|
||||||
has_prompt=has_prompt,
|
|
||||||
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.manifest.agent
|
|
||||||
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.manifest.agent
|
|
||||||
return prompt_path if plan.agent_provision.has_prompt or 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 {_SUPERVISE_MCP_NAME} --url "
|
|
||||||
f"{shlex.quote(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 supervise --url {shlex.quote(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}")
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
"""Host Codex auth helpers.
|
|
||||||
|
|
||||||
Reads the host's Codex ChatGPT/device-login auth state and returns only
|
|
||||||
the short-lived access token needed by egress. This module deliberately
|
|
||||||
does not expose refresh tokens or raw auth payloads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from ...log import die
|
|
||||||
from ...util import expand_tilde
|
|
||||||
|
|
||||||
|
|
||||||
def codex_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
|
||||||
env = os.environ if host_env is None else host_env
|
|
||||||
home = env.get("CODEX_HOME")
|
|
||||||
if home:
|
|
||||||
return Path(expand_tilde(home)) / "auth.json"
|
|
||||||
return Path.home() / ".codex" / "auth.json"
|
|
||||||
|
|
||||||
|
|
||||||
def codex_host_access_token(
|
|
||||||
host_env: dict[str, str] | None = None,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> str:
|
|
||||||
path = codex_auth_path(host_env)
|
|
||||||
if not path.is_file():
|
|
||||||
die(
|
|
||||||
f"codex host credentials: auth file missing at {path}. "
|
|
||||||
"Run `codex login --device-auth` on the host or disable "
|
|
||||||
"agent_provider.forward_host_credentials."
|
|
||||||
)
|
|
||||||
raw = _read_auth_object(path)
|
|
||||||
|
|
||||||
auth_mode = raw.get("auth_mode")
|
|
||||||
if not isinstance(auth_mode, str) or auth_mode == "api_key":
|
|
||||||
die(
|
|
||||||
"codex host credentials: host Codex auth is not user/device "
|
|
||||||
"auth. Run `codex login --device-auth` on the host."
|
|
||||||
)
|
|
||||||
|
|
||||||
tokens = raw.get("tokens")
|
|
||||||
if not isinstance(tokens, dict):
|
|
||||||
die(f"codex host credentials: {path} is missing tokens")
|
|
||||||
tokens_typed = cast(dict[str, object], tokens)
|
|
||||||
access = tokens_typed.get("access_token")
|
|
||||||
if not isinstance(access, str) or not access:
|
|
||||||
die(
|
|
||||||
f"codex host credentials: {path} is missing tokens.access_token. "
|
|
||||||
"Run `codex login --device-auth` on the host."
|
|
||||||
)
|
|
||||||
|
|
||||||
exp = _jwt_exp(access)
|
|
||||||
if exp is None:
|
|
||||||
die("codex host credentials: tokens.access_token is not a JWT with exp")
|
|
||||||
check_now = now or datetime.now(timezone.utc)
|
|
||||||
if exp <= check_now:
|
|
||||||
die(
|
|
||||||
"codex host credentials: host Codex access token is expired. "
|
|
||||||
"Run `codex login --device-auth` on the host and restart the bottle."
|
|
||||||
)
|
|
||||||
return access
|
|
||||||
|
|
||||||
|
|
||||||
def codex_dummy_auth_json(
|
|
||||||
host_env: dict[str, str] | None = None,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Return a non-secret `auth.json` that keeps Codex in the host's
|
|
||||||
auth branch while egress owns the real bearer token.
|
|
||||||
|
|
||||||
The dummy access/id tokens carry the *host* token's real `exp` so
|
|
||||||
Codex's proactive refresh lifecycle (it refreshes when its local
|
|
||||||
access token is at/past expiry) tracks the real token instead of
|
|
||||||
firing after an artificial TTL. Codex cannot refresh inside the
|
|
||||||
bottle — the refresh token is a placeholder and the OpenAI token
|
|
||||||
endpoint is off-route — so a shorter dummy exp would drop Codex to
|
|
||||||
the sign-in screen the moment it lapsed, even while egress still
|
|
||||||
holds a valid bearer."""
|
|
||||||
path = codex_auth_path(host_env)
|
|
||||||
access = codex_host_access_token(host_env, now=now)
|
|
||||||
raw = _read_auth_object(path)
|
|
||||||
host_exp = _jwt_exp(access)
|
|
||||||
exp_ts = int(host_exp.timestamp()) if host_exp is not None else None
|
|
||||||
dummy = _redact_codex_auth(deepcopy(raw), now=now, exp_ts=exp_ts)
|
|
||||||
return json.dumps(dummy, indent=2, sort_keys=True) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def write_codex_dummy_auth_file(
|
|
||||||
path: Path,
|
|
||||||
host_env: dict[str, str] | None = None,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(codex_dummy_auth_json(host_env, now=now))
|
|
||||||
path.chmod(0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_auth_object(path: Path) -> dict[str, object]:
|
|
||||||
try:
|
|
||||||
raw = json.loads(path.read_text())
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
die(f"codex host credentials: could not read valid JSON at {path}: {e}")
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
die(f"codex host credentials: {path} must contain a JSON object")
|
|
||||||
return cast(dict[str, object], raw)
|
|
||||||
|
|
||||||
|
|
||||||
def _dummy_exp(now: datetime | None, exp_ts: int | None) -> int:
|
|
||||||
if exp_ts is not None:
|
|
||||||
return exp_ts
|
|
||||||
check_now = now or datetime.now(timezone.utc)
|
|
||||||
return int(check_now.timestamp()) + 3600
|
|
||||||
|
|
||||||
|
|
||||||
def _dummy_timestamp(now: datetime | None = None) -> str:
|
|
||||||
check_now = now or datetime.now(timezone.utc)
|
|
||||||
if check_now.tzinfo is None:
|
|
||||||
check_now = check_now.replace(tzinfo=timezone.utc)
|
|
||||||
check_now = check_now.astimezone(timezone.utc)
|
|
||||||
return check_now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _dummy_jwt(now: datetime | None = None, *, exp_ts: int | None = None) -> str:
|
|
||||||
return _encode_dummy_jwt({
|
|
||||||
"exp": _dummy_exp(now, exp_ts),
|
|
||||||
"sub": "bot-bottle-placeholder",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _dummy_jwt_from_host(
|
|
||||||
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
|
||||||
) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
|
||||||
parts = value.split(".")
|
|
||||||
if len(parts) < 2:
|
|
||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
|
||||||
try:
|
|
||||||
payload = json.loads(_b64url_decode(parts[1]))
|
|
||||||
except (ValueError, json.JSONDecodeError):
|
|
||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return _dummy_jwt(now, exp_ts=exp_ts)
|
|
||||||
return _encode_dummy_jwt(_redact_jwt_payload(cast(dict[str, object], payload), now=now, exp_ts=exp_ts))
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_dummy_jwt(payload: dict[str, object]) -> str:
|
|
||||||
def enc(obj: dict[str, object]) -> str:
|
|
||||||
raw = json.dumps(obj, separators=(",", ":")).encode()
|
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
||||||
|
|
||||||
return f"{enc({'alg': 'none', 'typ': 'JWT'})}.{enc(payload)}.placeholder"
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_jwt_payload(
|
|
||||||
payload: dict[str, object],
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
exp_ts: int | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
out = _redact_claims(payload)
|
|
||||||
if not isinstance(out, dict):
|
|
||||||
out = {}
|
|
||||||
out_typed: dict[str, object] = cast(dict[str, object], out)
|
|
||||||
out_typed["exp"] = _dummy_exp(now, exp_ts)
|
|
||||||
out_typed.setdefault("sub", "bot-bottle-placeholder")
|
|
||||||
return out_typed
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_claims(value: object) -> object:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
out: dict[str, object] = {}
|
|
||||||
for key, inner in cast(dict[str, object], value).items():
|
|
||||||
lower = key.lower()
|
|
||||||
if key == "https://api.openai.com/profile":
|
|
||||||
out[key] = _redact_profile_claim(inner)
|
|
||||||
elif key == "https://api.openai.com/auth":
|
|
||||||
out[key] = _redact_auth_claim(inner)
|
|
||||||
elif lower == "email":
|
|
||||||
out[key] = "bot-bottle@example.invalid"
|
|
||||||
elif lower == "email_verified":
|
|
||||||
out[key] = True
|
|
||||||
elif lower in {"exp", "iat", "nbf", "auth_time", "pwd_auth_time"}:
|
|
||||||
out[key] = inner if isinstance(inner, (int, float)) else 0
|
|
||||||
elif lower in {"aud", "scp", "amr"}:
|
|
||||||
out[key] = inner if isinstance(inner, list) else []
|
|
||||||
elif isinstance(inner, bool):
|
|
||||||
out[key] = inner
|
|
||||||
elif isinstance(inner, dict):
|
|
||||||
out[key] = {}
|
|
||||||
elif isinstance(inner, list):
|
|
||||||
out[key] = []
|
|
||||||
else:
|
|
||||||
out[key] = "bot-bottle-placeholder"
|
|
||||||
return out
|
|
||||||
if isinstance(value, list):
|
|
||||||
return []
|
|
||||||
return "bot-bottle-placeholder"
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_profile_claim(value: object) -> dict[str, object]:
|
|
||||||
profile = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
|
||||||
return {
|
|
||||||
"email": "bot-bottle@example.invalid",
|
|
||||||
"email_verified": bool(profile.get("email_verified", True)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_auth_claim(value: object) -> dict[str, object]:
|
|
||||||
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
|
||||||
out: dict[str, object] = {}
|
|
||||||
for key, inner in auth.items():
|
|
||||||
lower = key.lower()
|
|
||||||
if lower == "chatgpt_plan_type" and isinstance(inner, str) and inner:
|
|
||||||
out[key] = inner
|
|
||||||
elif lower == "chatgpt_account_id" and isinstance(inner, str) and inner:
|
|
||||||
# Current Codex uses the selected account id when building
|
|
||||||
# ChatGPT requests. Keep that non-secret identifier aligned
|
|
||||||
# with the host while egress owns the real bearer token.
|
|
||||||
out[key] = inner
|
|
||||||
elif lower == "localhost" and isinstance(inner, bool):
|
|
||||||
out[key] = inner
|
|
||||||
elif isinstance(inner, bool):
|
|
||||||
out[key] = inner
|
|
||||||
elif isinstance(inner, list):
|
|
||||||
out[key] = []
|
|
||||||
elif isinstance(inner, dict):
|
|
||||||
out[key] = {}
|
|
||||||
else:
|
|
||||||
out[key] = "bot-bottle-placeholder"
|
|
||||||
out.setdefault("chatgpt_plan_type", "unknown")
|
|
||||||
out.setdefault("user_id", "bot-bottle-placeholder")
|
|
||||||
out.setdefault("chatgpt_user_id", "bot-bottle-placeholder")
|
|
||||||
out.setdefault("chatgpt_account_id", "bot-bottle-placeholder")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_codex_auth(
|
|
||||||
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
|
||||||
) -> object:
|
|
||||||
auth = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
|
||||||
out: dict[str, object] = {}
|
|
||||||
for key, inner in auth.items():
|
|
||||||
lower = key.lower()
|
|
||||||
if lower == "auth_mode" and isinstance(inner, str) and inner:
|
|
||||||
out[key] = inner
|
|
||||||
elif lower == "openai_api_key":
|
|
||||||
out[key] = None
|
|
||||||
elif lower == "last_refresh":
|
|
||||||
# Codex parses this as a timestamp on startup. Keep the
|
|
||||||
# schema valid without copying host-side session metadata.
|
|
||||||
out[key] = _dummy_timestamp(now)
|
|
||||||
elif lower == "tokens":
|
|
||||||
out[key] = _redact_token_block(inner, now=now, exp_ts=exp_ts)
|
|
||||||
else:
|
|
||||||
out[key] = _redact_unknown_auth_value(inner)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_token_block(
|
|
||||||
value: object, *, now: datetime | None = None, exp_ts: int | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
tokens = cast(dict[str, object], value) if isinstance(value, dict) else {}
|
|
||||||
out: dict[str, object] = {}
|
|
||||||
for key, inner in tokens.items():
|
|
||||||
lower = key.lower()
|
|
||||||
if lower in {"access_token", "id_token"}:
|
|
||||||
out[key] = _dummy_jwt_from_host(inner, now=now, exp_ts=exp_ts)
|
|
||||||
elif lower == "account_id" and isinstance(inner, str) and inner:
|
|
||||||
# Current Codex uses this non-secret selected account id
|
|
||||||
# while egress owns the real bearer token.
|
|
||||||
out[key] = inner
|
|
||||||
else:
|
|
||||||
out[key] = _redact_unknown_auth_value(inner)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_unknown_auth_value(value: object) -> object:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return []
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return "bot-bottle-placeholder"
|
|
||||||
|
|
||||||
|
|
||||||
def _jwt_exp(token: str) -> datetime | None:
|
|
||||||
parts = token.split(".")
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = json.loads(_b64url_decode(parts[1]))
|
|
||||||
except (ValueError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
exp = cast(dict[str, object], payload).get("exp")
|
|
||||||
if not isinstance(exp, (int, float)):
|
|
||||||
return None
|
|
||||||
return datetime.fromtimestamp(exp, timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64url_decode(value: str) -> str:
|
|
||||||
padded = value + ("=" * (-len(value) % 4))
|
|
||||||
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"codex_auth_path",
|
|
||||||
"codex_dummy_auth_json",
|
|
||||||
"codex_host_access_token",
|
|
||||||
"write_codex_dummy_auth_file",
|
|
||||||
]
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""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`.
|
|
||||||
|
|
||||||
Required token permissions (Gitea "Applications" → "Generate Token"):
|
|
||||||
- Repository: Read & Write
|
|
||||||
Grants POST /api/v1/repos/{owner}/{repo}/keys (create deploy key)
|
|
||||||
and DELETE /api/v1/repos/{owner}/{repo}/keys/{id} (revoke deploy key).
|
|
||||||
No other scopes are needed."""
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# bot-bottle Pi provider image.
|
|
||||||
#
|
|
||||||
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
|
|
||||||
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
fd-find \
|
|
||||||
ripgrep \
|
|
||||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
|
|
||||||
&& npm cache clean --force
|
|
||||||
|
|
||||||
RUN mkdir -p /home/node/.pi/agent \
|
|
||||||
/home/node/.pi/context-mode/sessions \
|
|
||||||
/tmp/pi-subagents-uid-1000 \
|
|
||||||
&& chown -R node:node /home/node/.pi /tmp \
|
|
||||||
&& chmod -R u+rwX /tmp \
|
|
||||||
&& chown root:root /tmp /var/tmp \
|
|
||||||
&& chmod 1777 /tmp /var/tmp
|
|
||||||
|
|
||||||
USER node
|
|
||||||
WORKDIR /home/node
|
|
||||||
|
|
||||||
RUN pi install npm:@harms-haus/pi-cwd \
|
|
||||||
&& pi install npm:pi-web-access \
|
|
||||||
&& pi install npm:context-mode \
|
|
||||||
&& pi install npm:pi-subagents \
|
|
||||||
&& pi install npm:pi-mcp-adapter
|
|
||||||
|
|
||||||
CMD ["pi"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pi agent provider package."""
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
"""Pi agent provider plugin (PRD 0058, contrib).
|
|
||||||
|
|
||||||
Pi uses ~/.pi/agent/models.json for custom provider/model settings.
|
|
||||||
This provider writes an Ollama-compatible default configuration and
|
|
||||||
lets bottles override the model endpoint and model ids via
|
|
||||||
agent_provider.settings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from ...agent_provider import (
|
|
||||||
AgentProvider,
|
|
||||||
AgentProviderRuntime,
|
|
||||||
AgentProvisionDir,
|
|
||||||
AgentProvisionFile,
|
|
||||||
AgentProvisionPlan,
|
|
||||||
)
|
|
||||||
from ...egress import EgressRoute
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ...backend import Bottle, BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_BASE_URL = "http://ollama:11434/v1"
|
|
||||||
_DEFAULT_MODEL = "qwen2.5-coder:7b"
|
|
||||||
_DEFAULT_PROVIDER_NAME = "ollama"
|
|
||||||
_DEFAULT_CONTEXT_WINDOW = 4096
|
|
||||||
_DEFAULT_MAX_TOKENS = 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir(guest_home: str) -> str:
|
|
||||||
return f"{guest_home}/.pi/agent/skills"
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_path(guest_home: str) -> str:
|
|
||||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
|
||||||
|
|
||||||
|
|
||||||
def _append_system_path(guest_home: str) -> str:
|
|
||||||
return f"{guest_home}/.pi/agent/APPEND_SYSTEM.md"
|
|
||||||
|
|
||||||
|
|
||||||
def _models_path(guest_home: str) -> str:
|
|
||||||
return f"{guest_home}/.pi/agent/models.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_state_repair_script(guest_home: str) -> str:
|
|
||||||
home = shlex.quote(guest_home)
|
|
||||||
pi_home = shlex.quote(f"{guest_home}/.pi")
|
|
||||||
context_sessions = shlex.quote(f"{guest_home}/.pi/context-mode/sessions")
|
|
||||||
return (
|
|
||||||
f"mkdir -p {context_sessions} /tmp/pi-subagents-uid-1000 && "
|
|
||||||
f"chown node:node {home} && "
|
|
||||||
f"chown -R node:node {pi_home} /tmp && "
|
|
||||||
"chmod -R u+rwX /tmp && "
|
|
||||||
f"chmod 755 {home} && "
|
|
||||||
"chown root:root /tmp /var/tmp && "
|
|
||||||
"chmod 1777 /tmp /var/tmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_value(
|
|
||||||
settings: dict[str, object],
|
|
||||||
key: str,
|
|
||||||
default: object,
|
|
||||||
) -> object:
|
|
||||||
value = settings.get(key)
|
|
||||||
return default if value is None else value
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_int(
|
|
||||||
settings: dict[str, object],
|
|
||||||
key: str,
|
|
||||||
default: int,
|
|
||||||
) -> int:
|
|
||||||
value = _settings_value(settings, key, default)
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return default
|
|
||||||
if isinstance(value, (int, str)):
|
|
||||||
return int(value)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _pi_models_json(
|
|
||||||
settings: dict[str, object],
|
|
||||||
) -> tuple[dict[str, object], str, str, list[str], str]:
|
|
||||||
provider_name = str(
|
|
||||||
_settings_value(settings, "provider", _DEFAULT_PROVIDER_NAME)
|
|
||||||
)
|
|
||||||
base_url = str(_settings_value(settings, "base_url", _DEFAULT_BASE_URL))
|
|
||||||
api = str(_settings_value(settings, "api", "openai-completions"))
|
|
||||||
api_key = settings.get("api_key")
|
|
||||||
api_key_env = str(settings.get("api_key_env", ""))
|
|
||||||
models_raw = _settings_value(settings, "models", [_DEFAULT_MODEL])
|
|
||||||
models = [str(model) for model in models_raw] # type: ignore[union-attr]
|
|
||||||
supports_developer_role = bool(
|
|
||||||
_settings_value(settings, "supports_developer_role", False)
|
|
||||||
)
|
|
||||||
supports_reasoning_effort = bool(
|
|
||||||
_settings_value(settings, "supports_reasoning_effort", False)
|
|
||||||
)
|
|
||||||
max_tokens_field = str(
|
|
||||||
_settings_value(settings, "max_tokens_field", "max_tokens")
|
|
||||||
)
|
|
||||||
context_window = _settings_int(
|
|
||||||
settings, "context_window", _DEFAULT_CONTEXT_WINDOW,
|
|
||||||
)
|
|
||||||
max_tokens = _settings_int(settings, "max_tokens", _DEFAULT_MAX_TOKENS)
|
|
||||||
input_context_window = max(1, context_window - max_tokens)
|
|
||||||
provider: dict[str, object] = {
|
|
||||||
"baseUrl": base_url,
|
|
||||||
"api": api,
|
|
||||||
"compat": {
|
|
||||||
"supportsDeveloperRole": supports_developer_role,
|
|
||||||
"supportsReasoningEffort": supports_reasoning_effort,
|
|
||||||
"maxTokensField": max_tokens_field,
|
|
||||||
},
|
|
||||||
"models": [
|
|
||||||
{
|
|
||||||
"id": model,
|
|
||||||
"name": model,
|
|
||||||
"contextWindow": input_context_window,
|
|
||||||
"maxTokens": max_tokens,
|
|
||||||
}
|
|
||||||
for model in models
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if api_key is not None:
|
|
||||||
provider["apiKey"] = str(api_key)
|
|
||||||
elif api_key_env:
|
|
||||||
provider["apiKey"] = "egress-placeholder"
|
|
||||||
elif provider_name == _DEFAULT_PROVIDER_NAME:
|
|
||||||
provider["apiKey"] = "ollama"
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"providers": {
|
|
||||||
provider_name: provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload, base_url, api_key_env, models, provider_name
|
|
||||||
|
|
||||||
|
|
||||||
def _route_host(base_url: str) -> str:
|
|
||||||
parsed = urlparse(base_url)
|
|
||||||
if not parsed.scheme or not parsed.hostname:
|
|
||||||
die(
|
|
||||||
"agent provider provisioning: pi settings base_url must be an "
|
|
||||||
f"absolute URL (was {base_url!r})"
|
|
||||||
)
|
|
||||||
return parsed.hostname
|
|
||||||
|
|
||||||
|
|
||||||
_RUNTIME = AgentProviderRuntime(
|
|
||||||
template="pi",
|
|
||||||
command="pi",
|
|
||||||
image="bot-bottle-pi:latest",
|
|
||||||
prompt_mode="append_system_prompt",
|
|
||||||
bypass_args=(),
|
|
||||||
resume_args=(),
|
|
||||||
remote_control_args=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PiAgentProvider(AgentProvider):
|
|
||||||
@property
|
|
||||||
def runtime(self) -> AgentProviderRuntime:
|
|
||||||
return _RUNTIME
|
|
||||||
|
|
||||||
def provision_plan(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
dockerfile: str,
|
|
||||||
state_dir: Path,
|
|
||||||
instance_name: str,
|
|
||||||
prompt_file: Path,
|
|
||||||
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 = "",
|
|
||||||
label: str = "",
|
|
||||||
color: str = "",
|
|
||||||
provider_settings: dict[str, object] | None = None,
|
|
||||||
) -> AgentProvisionPlan:
|
|
||||||
del auth_token, forward_host_credentials, host_env, trusted_project_path
|
|
||||||
del label, color
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
|
||||||
guest_home = self.guest_home
|
|
||||||
settings = dict(provider_settings or {})
|
|
||||||
|
|
||||||
models_payload, base_url, api_key_env, models, provider_name = (
|
|
||||||
_pi_models_json(settings)
|
|
||||||
)
|
|
||||||
models_file = state_dir / "pi-models.json"
|
|
||||||
models_file.write_text(json.dumps(models_payload, indent=2) + "\n")
|
|
||||||
models_file.chmod(0o600)
|
|
||||||
|
|
||||||
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
|
||||||
auth_scheme = "Bearer" if api_key_env else ""
|
|
||||||
return AgentProvisionPlan(
|
|
||||||
template=_RUNTIME.template,
|
|
||||||
command=_RUNTIME.command,
|
|
||||||
prompt_mode=_RUNTIME.prompt_mode,
|
|
||||||
image=_RUNTIME.image,
|
|
||||||
dockerfile=dockerfile,
|
|
||||||
guest_home=guest_home,
|
|
||||||
instance_name=instance_name,
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
guest_env=resolved_guest_env,
|
|
||||||
has_prompt=has_prompt,
|
|
||||||
startup_args=(
|
|
||||||
"--models",
|
|
||||||
",".join(f"{provider_name}/{model}" for model in models),
|
|
||||||
),
|
|
||||||
dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),),
|
|
||||||
files=(AgentProvisionFile(models_file, _models_path(guest_home)),),
|
|
||||||
egress_routes=(EgressRoute(
|
|
||||||
host=_route_host(base_url),
|
|
||||||
auth_scheme=auth_scheme,
|
|
||||||
token_ref=api_key_env,
|
|
||||||
),),
|
|
||||||
)
|
|
||||||
|
|
||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
|
||||||
from ...backend.util import host_skill_dir
|
|
||||||
|
|
||||||
agent = plan.manifest.agent
|
|
||||||
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:
|
|
||||||
prompt_path = _prompt_path(plan.guest_home)
|
|
||||||
append_system_path = _append_system_path(plan.guest_home)
|
|
||||||
bottle.cp_in(str(plan.prompt_file), prompt_path) # type: ignore
|
|
||||||
bottle.exec(
|
|
||||||
f"mkdir -p {shlex.quote(plan.guest_home)}/.pi/agent && "
|
|
||||||
f"cp {shlex.quote(prompt_path)} {shlex.quote(append_system_path)} && "
|
|
||||||
f"chown node:node {shlex.quote(prompt_path)} "
|
|
||||||
f"{shlex.quote(append_system_path)} && "
|
|
||||||
f"chmod 600 {shlex.quote(prompt_path)} "
|
|
||||||
f"{shlex.quote(append_system_path)}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
# Pi's `--append-system-prompt` takes literal text, not a file path.
|
|
||||||
# Use its documented APPEND_SYSTEM.md discovery path instead.
|
|
||||||
return None
|
|
||||||
|
|
||||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
|
||||||
provision = plan.agent_provision
|
|
||||||
_exec(
|
|
||||||
bottle,
|
|
||||||
_runtime_state_repair_script(plan.guest_home),
|
|
||||||
"could not prepare pi runtime state",
|
|
||||||
)
|
|
||||||
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 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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def provision_supervise_mcp(
|
|
||||||
self,
|
|
||||||
plan: "BottlePlan",
|
|
||||||
bottle: "Bottle",
|
|
||||||
supervise_url: str,
|
|
||||||
) -> None:
|
|
||||||
del plan, bottle, 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}")
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""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"
|
|
||||||
)
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
"""DLP detectors for the egress proxy (PRD 0053).
|
|
||||||
|
|
||||||
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
|
||||||
function returning `ScanResult | None`.
|
|
||||||
|
|
||||||
Ships flat into the sidecar bundle image alongside
|
|
||||||
`egress_addon_core.py` — both this file and the package source use
|
|
||||||
the same try/except import shim pattern.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import gzip
|
|
||||||
import re
|
|
||||||
import typing
|
|
||||||
import unicodedata
|
|
||||||
from urllib.parse import quote as url_quote
|
|
||||||
|
|
||||||
try:
|
|
||||||
from egress_addon_core import ScanResult # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .egress_addon_core import ScanResult
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Snippet helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SNIPPET_CONTEXT = 40 # chars of surrounding text to include on each side
|
|
||||||
REDACT = "********" # fixed-width replacement for the matched sensitive value
|
|
||||||
|
|
||||||
|
|
||||||
def _snippet(text: str, start: int, end: int) -> str:
|
|
||||||
"""Return context around a match with the matched span replaced by REDACT."""
|
|
||||||
before = text[max(0, start - SNIPPET_CONTEXT):start].replace("\n", " ").replace("\r", " ")
|
|
||||||
after = text[end:end + SNIPPET_CONTEXT].replace("\n", " ").replace("\r", " ")
|
|
||||||
return f"{before}{REDACT}{after}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Unicode normalization (defeats confusable-char and combining-mark evasion)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _normalize_text(text: str) -> str:
|
|
||||||
# NFKD separates base characters from combining marks and resolves
|
|
||||||
# compatibility equivalents (fullwidth ASCII, ligatures, etc.)
|
|
||||||
decomposed = unicodedata.normalize("NFKD", text)
|
|
||||||
return "".join(
|
|
||||||
ch for ch in decomposed
|
|
||||||
# Strip combining marks inserted between chars to break patterns
|
|
||||||
if unicodedata.category(ch) != "Mn"
|
|
||||||
# Strip control chars; keep common whitespace (\n \r \t)
|
|
||||||
and (unicodedata.category(ch) != "Cc" or ch in "\n\r\t")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Token patterns detector
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TOKEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
||||||
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
|
||||||
("GitHub token (classic)", re.compile(r"ghp_[A-Za-z0-9_]{36}")),
|
|
||||||
("GitHub fine-grained token", re.compile(r"github_pat_[A-Za-z0-9_]{82}")),
|
|
||||||
("Anthropic API key", re.compile(r"sk-ant-[A-Za-z0-9\-_]{93}")),
|
|
||||||
("OpenAI API key", re.compile(r"sk-[A-Za-z0-9]{48}")),
|
|
||||||
("OpenAI project API key", re.compile(r"sk-proj-[A-Za-z0-9_\-]{48,}")),
|
|
||||||
("Stripe live key", re.compile(r"sk_live_[A-Za-z0-9]{24}")),
|
|
||||||
("Generic Bearer JWT", re.compile(r"Bearer\s+[A-Za-z0-9._\-]{50,}")),
|
|
||||||
("HuggingFace token", re.compile(r"hf_[A-Za-z0-9]{34,}")),
|
|
||||||
("Databricks token", re.compile(r"dapi[A-Za-z0-9]{32}")),
|
|
||||||
("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]{24,}")),
|
|
||||||
("npm token", re.compile(r"npm_[A-Za-z0-9]{36}")),
|
|
||||||
("SendGrid API key", re.compile(r"SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}")),
|
|
||||||
("PyPI token", re.compile(r"pypi-[A-Za-z0-9_\-]{80,}")),
|
|
||||||
("HashiCorp Vault token", re.compile(r"hvs\.[A-Za-z0-9_\-]{24,}")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_token_patterns(text: str, *, location: str = "body") -> ScanResult | None:
|
|
||||||
normalized = _normalize_text(text)
|
|
||||||
for name, pattern in TOKEN_PATTERNS:
|
|
||||||
m = pattern.search(normalized)
|
|
||||||
if m is not None:
|
|
||||||
return ScanResult(
|
|
||||||
severity="block",
|
|
||||||
reason=f"{name} found in {location}",
|
|
||||||
location=location,
|
|
||||||
context=_snippet(text, m.start(), m.end()),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def redact_tokens(
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
env: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Replace token pattern matches and (if env given) provisioned secrets with REDACT."""
|
|
||||||
for _, pattern in TOKEN_PATTERNS:
|
|
||||||
text = pattern.sub(REDACT, text)
|
|
||||||
if env is not None:
|
|
||||||
for key, value in env.items():
|
|
||||||
if key.startswith("EGRESS_TOKEN_") and value:
|
|
||||||
for variant in _encoded_variants(value):
|
|
||||||
text = text.replace(variant, REDACT)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Known secrets detector (Phase 1b)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _encoded_variants(secret: str) -> list[str]:
|
|
||||||
"""Return the secret plus common encoded variants for exfil detection."""
|
|
||||||
seen: set[str] = {secret}
|
|
||||||
variants: list[str] = [secret]
|
|
||||||
|
|
||||||
def _add(v: str) -> None:
|
|
||||||
if v not in seen:
|
|
||||||
seen.add(v)
|
|
||||||
variants.append(v)
|
|
||||||
|
|
||||||
secret_bytes = secret.encode("utf-8")
|
|
||||||
|
|
||||||
# Standard base64 — with and without padding
|
|
||||||
b64 = base64.b64encode(secret_bytes).decode("ascii")
|
|
||||||
_add(b64)
|
|
||||||
_add(b64.rstrip("="))
|
|
||||||
|
|
||||||
# URL-safe base64 (JWT/OAuth use -_ alphabet) — with and without padding
|
|
||||||
b64url = base64.urlsafe_b64encode(secret_bytes).decode("ascii")
|
|
||||||
_add(b64url)
|
|
||||||
_add(b64url.rstrip("="))
|
|
||||||
|
|
||||||
# URL percent-encoding
|
|
||||||
_add(url_quote(secret, safe=""))
|
|
||||||
|
|
||||||
# Hex — lowercase and uppercase
|
|
||||||
_add(secret_bytes.hex())
|
|
||||||
_add(secret_bytes.hex().upper())
|
|
||||||
|
|
||||||
# Base32 (TOTP seeds, some DNS-exfil channels)
|
|
||||||
_add(base64.b32encode(secret_bytes).decode("ascii"))
|
|
||||||
|
|
||||||
# gzip + base64 (deterministic: mtime=0); recognisable by H4sI prefix
|
|
||||||
_add(base64.b64encode(gzip.compress(secret_bytes, mtime=0)).decode("ascii"))
|
|
||||||
|
|
||||||
return variants
|
|
||||||
|
|
||||||
|
|
||||||
def scan_known_secrets(
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
location: str = "body",
|
|
||||||
env: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> ScanResult | None:
|
|
||||||
if env is None:
|
|
||||||
return None
|
|
||||||
for key, value in env.items():
|
|
||||||
if not key.startswith("EGRESS_TOKEN_") or not value:
|
|
||||||
continue
|
|
||||||
for variant in _encoded_variants(value):
|
|
||||||
pos = text.find(variant)
|
|
||||||
if pos >= 0:
|
|
||||||
return ScanResult(
|
|
||||||
severity="block",
|
|
||||||
reason=f"provisioned secret from {key} found in {location}",
|
|
||||||
location=location,
|
|
||||||
context=_snippet(text, pos, pos + len(variant)),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Naive prompt injection detector (Phase 2)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DISCLOSURE_PHRASES: tuple[re.Pattern[str], ...] = (
|
|
||||||
re.compile(r"(?i)system\s+prompt"),
|
|
||||||
re.compile(r"(?i)my\s+instructions\s+are"),
|
|
||||||
re.compile(r"(?i)original\s+instructions"),
|
|
||||||
re.compile(r"(?i)secret\s+instructions"),
|
|
||||||
re.compile(r"(?i)hidden\s+rules"),
|
|
||||||
)
|
|
||||||
|
|
||||||
JAILBREAK_PHRASES: tuple[re.Pattern[str], ...] = (
|
|
||||||
re.compile(r"(?i)ignore\s+previous"),
|
|
||||||
re.compile(r"(?i)forget\s+everything"),
|
|
||||||
re.compile(r"(?i)disregard\s+(?:all\s+)?(?:previous|prior)"),
|
|
||||||
re.compile(r"(?i)pretend\s+you\s+are"),
|
|
||||||
re.compile(r"(?i)act\s+as\s+(?:if|though)"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PROXIMITY_CHARS = 500
|
|
||||||
|
|
||||||
|
|
||||||
def _closest_pair(
|
|
||||||
a_matches: list[re.Match[str]],
|
|
||||||
b_matches: list[re.Match[str]],
|
|
||||||
) -> tuple[re.Match[str], re.Match[str]] | None:
|
|
||||||
"""Return the pair (a, b) with the smallest character gap, or None."""
|
|
||||||
best: tuple[re.Match[str], re.Match[str]] | None = None
|
|
||||||
best_gap: int | None = None
|
|
||||||
for a in a_matches:
|
|
||||||
for b in b_matches:
|
|
||||||
gap = max(0, max(a.start(), b.start()) - min(a.end(), b.end()))
|
|
||||||
if best_gap is None or gap < best_gap:
|
|
||||||
best_gap = gap
|
|
||||||
best = (a, b)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def scan_naive_injection(text: str) -> ScanResult | None:
|
|
||||||
location = "response body"
|
|
||||||
disclosure_hits = [m for p in DISCLOSURE_PHRASES for m in p.finditer(text)]
|
|
||||||
jailbreak_hits = [m for p in JAILBREAK_PHRASES for m in p.finditer(text)]
|
|
||||||
|
|
||||||
if disclosure_hits and jailbreak_hits:
|
|
||||||
pair = _closest_pair(disclosure_hits, jailbreak_hits)
|
|
||||||
if pair is not None:
|
|
||||||
dist = max(0, max(pair[0].start(), pair[1].start()) - min(pair[0].end(), pair[1].end()))
|
|
||||||
if dist <= PROXIMITY_CHARS:
|
|
||||||
first = pair[0] if pair[0].start() <= pair[1].start() else pair[1]
|
|
||||||
return ScanResult(
|
|
||||||
severity="block",
|
|
||||||
reason=(
|
|
||||||
f"disclosure and jailbreak phrases within "
|
|
||||||
f"{dist} chars in {location}"
|
|
||||||
),
|
|
||||||
location=location,
|
|
||||||
context=_snippet(text, first.start(), first.end()),
|
|
||||||
)
|
|
||||||
|
|
||||||
if disclosure_hits:
|
|
||||||
m = disclosure_hits[0]
|
|
||||||
return ScanResult(
|
|
||||||
severity="warn",
|
|
||||||
reason=f"prompt disclosure phrase detected in {location}",
|
|
||||||
location=location,
|
|
||||||
context=_snippet(text, m.start(), m.end()),
|
|
||||||
)
|
|
||||||
|
|
||||||
if jailbreak_hits:
|
|
||||||
m = jailbreak_hits[0]
|
|
||||||
return ScanResult(
|
|
||||||
severity="warn",
|
|
||||||
reason=f"jailbreak phrase detected in {location}",
|
|
||||||
location=location,
|
|
||||||
context=_snippet(text, m.start(), m.end()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CRLF injection detector
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# URL-encoded CRLF is never legitimate in a request URL or header value.
|
|
||||||
_CRLF_ENCODED_RE = re.compile(r"%0[dD]%0[aA]", re.ASCII)
|
|
||||||
# Literal CRLF followed by a header-name pattern indicates header injection.
|
|
||||||
_CRLF_HEADER_INJECT_RE = re.compile(r"\r\n[A-Za-z][A-Za-z0-9\-]+\s*:", re.ASCII)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_crlf_injection(text: str) -> ScanResult | None:
|
|
||||||
if _CRLF_ENCODED_RE.search(text):
|
|
||||||
return ScanResult(
|
|
||||||
severity="block",
|
|
||||||
reason="URL-encoded CRLF (%0d%0a) in outbound request",
|
|
||||||
)
|
|
||||||
if _CRLF_HEADER_INJECT_RE.search(text):
|
|
||||||
return ScanResult(
|
|
||||||
severity="block",
|
|
||||||
reason="CRLF header injection pattern in outbound request",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"REDACT",
|
|
||||||
"SNIPPET_CONTEXT",
|
|
||||||
"TOKEN_PATTERNS",
|
|
||||||
"redact_tokens",
|
|
||||||
"scan_crlf_injection",
|
|
||||||
"scan_known_secrets",
|
|
||||||
"scan_naive_injection",
|
|
||||||
"scan_token_patterns",
|
|
||||||
]
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
"""Per-bottle egress proxy (PRD 0017, PRD 0053).
|
|
||||||
|
|
||||||
This module defines the abstract proxy (`Egress`), its plan
|
|
||||||
dataclass (`EgressPlan`), and the resolved per-route shape
|
|
||||||
(`EgressRoute`). The sidecar's start/stop lifecycle is backend-
|
|
||||||
specific and lives on concrete subclasses (see
|
|
||||||
`bot_bottle/backend/docker/egress.py`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
from abc import ABC
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .egress_addon_core import (
|
|
||||||
HeaderMatch as CoreHeaderMatch,
|
|
||||||
MatchEntry as CoreMatchEntry,
|
|
||||||
PathMatch as CorePathMatch,
|
|
||||||
Route,
|
|
||||||
)
|
|
||||||
from .log import die
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .manifest import ManifestBottle
|
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
|
||||||
|
|
||||||
EGRESS_HOSTNAME = "egress"
|
|
||||||
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
|
||||||
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EgressRoute(Route):
|
|
||||||
"""Host-side extension of the addon's `Route`.
|
|
||||||
|
|
||||||
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
|
|
||||||
from `egress_addon_core.Route` — those are the fields that cross the
|
|
||||||
YAML wire into the sidecar. The fields below are host-only and
|
|
||||||
are never serialised to the addon.
|
|
||||||
|
|
||||||
`token_ref` is the host env var the CLI reads at launch and forwards
|
|
||||||
into the container's environ under `token_env`.
|
|
||||||
|
|
||||||
`roles` carries the manifest route's role tuple (reserved for
|
|
||||||
future use; always empty today)."""
|
|
||||||
|
|
||||||
token_ref: str = ""
|
|
||||||
roles: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EgressPlan:
|
|
||||||
slug: str
|
|
||||||
routes_path: Path
|
|
||||||
routes: tuple[EgressRoute, ...]
|
|
||||||
token_env_map: dict[str, str]
|
|
||||||
internal_network: str = ""
|
|
||||||
egress_network: str = ""
|
|
||||||
mitmproxy_ca_host_path: Path = Path()
|
|
||||||
mitmproxy_ca_cert_only_host_path: Path = Path()
|
|
||||||
log: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def egress_manifest_routes(
|
|
||||||
bottle: ManifestBottle,
|
|
||||||
) -> tuple[EgressRoute, ...]:
|
|
||||||
out: list[EgressRoute] = []
|
|
||||||
for r in bottle.egress.routes:
|
|
||||||
core_matches: list[CoreMatchEntry] = []
|
|
||||||
for m in r.Matches:
|
|
||||||
core_paths = tuple(
|
|
||||||
CorePathMatch(type=p.Type, value=p.Value)
|
|
||||||
for p in m.Paths
|
|
||||||
)
|
|
||||||
core_headers = tuple(
|
|
||||||
CoreHeaderMatch(name=h.Name, value=h.Value, type=h.Type)
|
|
||||||
for h in m.Headers
|
|
||||||
)
|
|
||||||
core_matches.append(CoreMatchEntry(
|
|
||||||
paths=core_paths,
|
|
||||||
methods=m.Methods,
|
|
||||||
headers=core_headers,
|
|
||||||
))
|
|
||||||
out.append(EgressRoute(
|
|
||||||
host=r.Host,
|
|
||||||
matches=tuple(core_matches),
|
|
||||||
auth_scheme=r.AuthScheme,
|
|
||||||
token_ref=r.TokenRef,
|
|
||||||
roles=r.Role,
|
|
||||||
git_fetch=r.GitFetch,
|
|
||||||
outbound_detectors=r.OutboundDetectors,
|
|
||||||
inbound_detectors=r.InboundDetectors,
|
|
||||||
))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_routes_for_bottle(
|
|
||||||
bottle: ManifestBottle,
|
|
||||||
provider_routes: tuple[EgressRoute, ...] = (),
|
|
||||||
) -> tuple[EgressRoute, ...]:
|
|
||||||
manifest = egress_manifest_routes(bottle)
|
|
||||||
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
|
||||||
merged = list(provider_routes) + [
|
|
||||||
r for r in manifest if r.host.lower() not in provisioned_hosts
|
|
||||||
]
|
|
||||||
return _assign_token_slots(merged)
|
|
||||||
|
|
||||||
|
|
||||||
def _assign_token_slots(
|
|
||||||
routes: list[EgressRoute],
|
|
||||||
) -> tuple[EgressRoute, ...]:
|
|
||||||
slot_for_ref: dict[str, str] = {}
|
|
||||||
out: list[EgressRoute] = []
|
|
||||||
for r in routes:
|
|
||||||
if r.auth_scheme and r.token_ref:
|
|
||||||
slot = slot_for_ref.get(r.token_ref)
|
|
||||||
if slot is None:
|
|
||||||
slot = f"EGRESS_TOKEN_{len(slot_for_ref)}"
|
|
||||||
slot_for_ref[r.token_ref] = slot
|
|
||||||
out.append(dataclasses.replace(r, token_env=slot))
|
|
||||||
else:
|
|
||||||
out.append(r)
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_token_env_map(
|
|
||||||
routes: tuple[EgressRoute, ...],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for r in routes:
|
|
||||||
if not (r.auth_scheme and r.token_ref and r.token_env):
|
|
||||||
continue
|
|
||||||
existing = out.get(r.token_env)
|
|
||||||
if existing is not None and existing != r.token_ref:
|
|
||||||
die(
|
|
||||||
f"egress plan conflict: {r.token_env} maps to both "
|
|
||||||
f"{existing!r} and {r.token_ref!r}. Two routes sharing a "
|
|
||||||
f"token slot must reference the same host env var."
|
|
||||||
)
|
|
||||||
out[r.token_env] = r.token_ref
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
|
||||||
fields: dict[str, object] = {"host": r.host}
|
|
||||||
if r.auth_scheme and r.token_env:
|
|
||||||
fields["auth_scheme"] = r.auth_scheme
|
|
||||||
fields["token_env"] = r.token_env
|
|
||||||
if r.matches:
|
|
||||||
matches_data: list[dict[str, object]] = []
|
|
||||||
for entry in r.matches:
|
|
||||||
entry_data: dict[str, object] = {}
|
|
||||||
if entry.paths:
|
|
||||||
paths_data: list[dict[str, str]] = []
|
|
||||||
for pm in entry.paths:
|
|
||||||
pd: dict[str, str] = {"value": pm.value}
|
|
||||||
if pm.type != "prefix":
|
|
||||||
pd["type"] = pm.type
|
|
||||||
paths_data.append(pd)
|
|
||||||
entry_data["paths"] = paths_data
|
|
||||||
if entry.methods:
|
|
||||||
entry_data["methods"] = list(entry.methods)
|
|
||||||
if entry.headers:
|
|
||||||
headers_data: list[dict[str, str]] = []
|
|
||||||
for hm in entry.headers:
|
|
||||||
hd: dict[str, str] = {"name": hm.name, "value": hm.value}
|
|
||||||
if hm.type != "exact":
|
|
||||||
hd["type"] = hm.type
|
|
||||||
headers_data.append(hd)
|
|
||||||
entry_data["headers"] = headers_data
|
|
||||||
matches_data.append(entry_data)
|
|
||||||
fields["matches"] = matches_data
|
|
||||||
if r.git_fetch:
|
|
||||||
fields["git"] = {"fetch": True}
|
|
||||||
if r.outbound_detectors is not None or r.inbound_detectors is not None:
|
|
||||||
dlp: dict[str, object] = {}
|
|
||||||
if r.outbound_detectors is not None:
|
|
||||||
dlp["outbound_detectors"] = (
|
|
||||||
False if not r.outbound_detectors
|
|
||||||
else list(r.outbound_detectors)
|
|
||||||
)
|
|
||||||
if r.inbound_detectors is not None:
|
|
||||||
dlp["inbound_detectors"] = (
|
|
||||||
False if not r.inbound_detectors
|
|
||||||
else list(r.inbound_detectors)
|
|
||||||
)
|
|
||||||
fields["dlp"] = dlp
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
|
||||||
lines: list[str] = []
|
|
||||||
first_key = True
|
|
||||||
if "paths" in entry:
|
|
||||||
lines.append(" - paths:")
|
|
||||||
first_key = False
|
|
||||||
for pd in entry["paths"]: # type: ignore[union-attr]
|
|
||||||
pd_dict: dict[str, str] = pd # type: ignore[assignment]
|
|
||||||
if "type" in pd_dict:
|
|
||||||
lines.append(f' - type: "{pd_dict["type"]}"')
|
|
||||||
lines.append(f' value: "{pd_dict["value"]}"')
|
|
||||||
else:
|
|
||||||
lines.append(f' - value: "{pd_dict["value"]}"')
|
|
||||||
if "methods" in entry:
|
|
||||||
methods_str = ", ".join(f'"{m}"' for m in entry["methods"]) # type: ignore[union-attr]
|
|
||||||
prefix = " - " if first_key else " "
|
|
||||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
|
||||||
first_key = False
|
|
||||||
if "headers" in entry:
|
|
||||||
prefix = " - " if first_key else " "
|
|
||||||
lines.append(f"{prefix}headers:")
|
|
||||||
first_key = False
|
|
||||||
for hd in entry["headers"]: # type: ignore[union-attr]
|
|
||||||
hd_dict: dict[str, str] = hd # type: ignore[assignment]
|
|
||||||
lines.append(f' - name: "{hd_dict["name"]}"')
|
|
||||||
lines.append(f' value: "{hd_dict["value"]}"')
|
|
||||||
if first_key:
|
|
||||||
lines.append(" - {}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def egress_render_routes(
|
|
||||||
routes: tuple[EgressRoute, ...],
|
|
||||||
*,
|
|
||||||
log: int = 0,
|
|
||||||
) -> str:
|
|
||||||
lines: list[str] = []
|
|
||||||
if log:
|
|
||||||
lines.append(f"log: {log}")
|
|
||||||
lines.append("routes:")
|
|
||||||
if not routes:
|
|
||||||
lines[-1] = "routes: []"
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
for r in routes:
|
|
||||||
f = _route_to_yaml_fields(r)
|
|
||||||
lines.append(f' - host: "{f["host"]}"')
|
|
||||||
if "auth_scheme" in f:
|
|
||||||
lines.append(f' auth_scheme: "{f["auth_scheme"]}"')
|
|
||||||
lines.append(f' token_env: "{f["token_env"]}"')
|
|
||||||
if "matches" in f:
|
|
||||||
lines.append(" matches:")
|
|
||||||
for entry in f["matches"]: # type: ignore[union-attr]
|
|
||||||
lines.extend(_render_match_entry(entry)) # type: ignore[arg-type]
|
|
||||||
if "git" in f:
|
|
||||||
git_dict: dict[str, object] = f["git"] # type: ignore
|
|
||||||
lines.append(" git:")
|
|
||||||
if git_dict.get("fetch") is True:
|
|
||||||
lines.append(" fetch: true")
|
|
||||||
if "dlp" in f:
|
|
||||||
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
|
||||||
lines.append(" dlp:")
|
|
||||||
for dk, dv in dlp_dict.items():
|
|
||||||
if dv is False:
|
|
||||||
lines.append(f" {dk}: false")
|
|
||||||
elif isinstance(dv, list):
|
|
||||||
items_str = ", ".join(f'"{x}"' for x in dv)
|
|
||||||
lines.append(f" {dk}: [{items_str}]")
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_resolve_token_values(
|
|
||||||
token_env_map: dict[str, str],
|
|
||||||
host_env: dict[str, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for token_env, token_ref in token_env_map.items():
|
|
||||||
value = host_env.get(token_ref)
|
|
||||||
if value is None:
|
|
||||||
die(
|
|
||||||
f"egress: host env var '{token_ref}' is unset. Set it "
|
|
||||||
f"before launching, or remove the corresponding auth block "
|
|
||||||
f"from bottle.egress.routes."
|
|
||||||
)
|
|
||||||
if not value:
|
|
||||||
die(
|
|
||||||
f"egress: host env var '{token_ref}' is empty. The "
|
|
||||||
f"egress will not inject an empty token; set it to "
|
|
||||||
f"the real value or remove the route's auth block."
|
|
||||||
)
|
|
||||||
out[token_env] = value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class Egress(ABC):
|
|
||||||
def prepare(
|
|
||||||
self,
|
|
||||||
bottle: ManifestBottle,
|
|
||||||
slug: str,
|
|
||||||
stage_dir: Path,
|
|
||||||
provider_routes: tuple[EgressRoute, ...] = (),
|
|
||||||
) -> EgressPlan:
|
|
||||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
|
||||||
log = bottle.egress.Log
|
|
||||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
|
||||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
return EgressPlan(
|
|
||||||
slug=slug,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=routes,
|
|
||||||
token_env_map=egress_token_env_map(routes),
|
|
||||||
log=log,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
|
||||||
"EGRESS_HOSTNAME",
|
|
||||||
"EGRESS_ROUTES_FILENAME",
|
|
||||||
"EGRESS_ROUTES_IN_CONTAINER",
|
|
||||||
"Egress",
|
|
||||||
"EgressPlan",
|
|
||||||
"EgressRoute",
|
|
||||||
"egress_manifest_routes",
|
|
||||||
"egress_render_routes",
|
|
||||||
"egress_resolve_token_values",
|
|
||||||
"egress_routes_for_bottle",
|
|
||||||
"egress_token_env_map",
|
|
||||||
]
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
"""mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053).
|
|
||||||
|
|
||||||
Loaded by `mitmdump -s /app/egress_addon.py` inside the
|
|
||||||
egress container."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
|
||||||
|
|
||||||
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
|
||||||
LOG_BLOCKS,
|
|
||||||
LOG_FULL,
|
|
||||||
Config,
|
|
||||||
build_inbound_scan_text,
|
|
||||||
build_outbound_scan_text,
|
|
||||||
decide,
|
|
||||||
decide_git_fetch,
|
|
||||||
is_git_fetch_request,
|
|
||||||
is_git_push_request,
|
|
||||||
load_config,
|
|
||||||
match_route,
|
|
||||||
outbound_scan_headers,
|
|
||||||
route_to_yaml_dict,
|
|
||||||
scan_inbound,
|
|
||||||
scan_outbound,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from bot_bottle.dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
|
||||||
|
|
||||||
|
|
||||||
class EgressAddon:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
|
||||||
self.config: Config = Config(routes=())
|
|
||||||
self._reload(initial=True)
|
|
||||||
self._install_sighup()
|
|
||||||
|
|
||||||
def _reload(self, *, initial: bool = False) -> None:
|
|
||||||
try:
|
|
||||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
|
||||||
new_config = load_config(text)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
tag = "boot" if initial else "SIGHUP"
|
|
||||||
sys.stderr.write(
|
|
||||||
f"egress: {tag} load failed: {e}\n"
|
|
||||||
)
|
|
||||||
if initial:
|
|
||||||
self.config = Config(routes=())
|
|
||||||
return
|
|
||||||
self.config = new_config
|
|
||||||
log_label = ("off", "blocks", "full")[self.config.log]
|
|
||||||
sys.stderr.write(
|
|
||||||
f"egress: loaded {len(self.config.routes)} route(s): "
|
|
||||||
f"{', '.join(r.host for r in self.config.routes)}"
|
|
||||||
f" [log={log_label}]\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _install_sighup(self) -> None:
|
|
||||||
if not hasattr(signal, "SIGHUP"):
|
|
||||||
return
|
|
||||||
|
|
||||||
def handler(signum: int, frame: object) -> None:
|
|
||||||
del signum, frame
|
|
||||||
self._reload()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGHUP, handler)
|
|
||||||
|
|
||||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
|
||||||
if path == "/allowlist":
|
|
||||||
payload = json.dumps(
|
|
||||||
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
|
||||||
indent=2,
|
|
||||||
).encode("utf-8")
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
200, payload,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
404,
|
|
||||||
f"egress introspection: no such endpoint {path!r}".encode(),
|
|
||||||
{"Content-Type": "text/plain; charset=utf-8"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
|
||||||
"method": flow.request.method,
|
|
||||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _block(
|
|
||||||
self,
|
|
||||||
flow: http.HTTPFlow,
|
|
||||||
reason: str,
|
|
||||||
ctx: dict[str, object] | None = None,
|
|
||||||
) -> None:
|
|
||||||
if self.config.log >= LOG_BLOCKS:
|
|
||||||
entry: dict[str, object] = {"event": "egress_block", "reason": reason}
|
|
||||||
if ctx:
|
|
||||||
entry.update(ctx)
|
|
||||||
sys.stderr.write(json.dumps(entry) + "\n")
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
403,
|
|
||||||
reason.encode("utf-8"),
|
|
||||||
{"Content-Type": "text/plain; charset=utf-8"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
|
||||||
sys.stderr.write(
|
|
||||||
json.dumps({
|
|
||||||
"event": "egress_request",
|
|
||||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
|
||||||
"method": flow.request.method,
|
|
||||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
|
||||||
"headers": dict(flow.request.headers),
|
|
||||||
"body": flow.request.get_text(strict=False) or "",
|
|
||||||
})
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
|
||||||
sys.stderr.write(
|
|
||||||
json.dumps({
|
|
||||||
"event": "egress_response",
|
|
||||||
"host": flow.request.pretty_host,
|
|
||||||
"status": flow.response.status_code,
|
|
||||||
"headers": dict(flow.response.headers),
|
|
||||||
"body": flow.response.get_text(strict=False) or "",
|
|
||||||
})
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def request(self, flow: http.HTTPFlow) -> None:
|
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
|
||||||
|
|
||||||
if flow.request.pretty_host == INTROSPECT_HOST:
|
|
||||||
self._serve_introspection(flow, request_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
|
||||||
# agent tried to smuggle in any header, path, query param, or body.
|
|
||||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
|
||||||
if route is not None:
|
|
||||||
body = flow.request.get_text(strict=False) or ""
|
|
||||||
scan_text = build_outbound_scan_text(
|
|
||||||
flow.request.pretty_host,
|
|
||||||
request_path,
|
|
||||||
query,
|
|
||||||
outbound_scan_headers(route, dict(flow.request.headers)),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
dlp_result = scan_outbound(route, scan_text, os.environ)
|
|
||||||
if dlp_result is not None and dlp_result.severity == "block":
|
|
||||||
ctx = self._req_ctx(flow)
|
|
||||||
if dlp_result.context:
|
|
||||||
ctx = {**ctx, "context": dlp_result.context}
|
|
||||||
self._block(flow, f"egress DLP: {dlp_result.reason}", ctx=ctx)
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_git_push_request(request_path, query):
|
|
||||||
self._block(
|
|
||||||
flow,
|
|
||||||
"egress: git push over HTTPS is not supported; "
|
|
||||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
|
||||||
"git-gate's pre-receive hook).",
|
|
||||||
ctx=self._req_ctx(flow),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_git_fetch_request(request_path, query):
|
|
||||||
git_decision = decide_git_fetch(
|
|
||||||
self.config.routes, flow.request.pretty_host,
|
|
||||||
)
|
|
||||||
if git_decision.action == "block":
|
|
||||||
self._block(
|
|
||||||
flow,
|
|
||||||
git_decision.reason,
|
|
||||||
ctx=self._req_ctx(flow),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
|
||||||
# are caught above; the route may inject sidecar-owned auth below.
|
|
||||||
flow.request.headers.pop("authorization", None)
|
|
||||||
|
|
||||||
# Build headers mapping for match evaluation
|
|
||||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
|
||||||
|
|
||||||
decision = decide(
|
|
||||||
self.config.routes,
|
|
||||||
flow.request.pretty_host,
|
|
||||||
request_path,
|
|
||||||
os.environ,
|
|
||||||
request_method=flow.request.method,
|
|
||||||
request_headers=req_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if decision.action == "block":
|
|
||||||
self._block(flow, decision.reason, ctx=self._req_ctx(flow))
|
|
||||||
return
|
|
||||||
|
|
||||||
if decision.inject_authorization is not None:
|
|
||||||
flow.request.headers["authorization"] = decision.inject_authorization
|
|
||||||
|
|
||||||
if self.config.log >= LOG_FULL:
|
|
||||||
self._log_request(flow)
|
|
||||||
|
|
||||||
def response(self, flow: http.HTTPFlow) -> None:
|
|
||||||
"""DLP inbound scan on response headers and body."""
|
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
|
||||||
if route is None:
|
|
||||||
return
|
|
||||||
if flow.response is None:
|
|
||||||
return
|
|
||||||
if self.config.log >= LOG_FULL:
|
|
||||||
self._log_response(flow)
|
|
||||||
resp_headers = {k.lower(): v for k, v in flow.response.headers.items()}
|
|
||||||
body = flow.response.get_text(strict=False) or ""
|
|
||||||
scan_text = build_inbound_scan_text(resp_headers, body)
|
|
||||||
if not scan_text:
|
|
||||||
return
|
|
||||||
result = scan_inbound(route, scan_text)
|
|
||||||
if result is None:
|
|
||||||
return
|
|
||||||
resp_ctx: dict[str, object] = {
|
|
||||||
**self._req_ctx(flow),
|
|
||||||
"response_status": flow.response.status_code,
|
|
||||||
}
|
|
||||||
if result.context:
|
|
||||||
resp_ctx = {**resp_ctx, "context": result.context}
|
|
||||||
if result.severity == "block":
|
|
||||||
self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx)
|
|
||||||
elif result.severity == "warn" and self.config.log >= LOG_BLOCKS:
|
|
||||||
sys.stderr.write(
|
|
||||||
json.dumps({
|
|
||||||
"event": "egress_warn",
|
|
||||||
"reason": f"egress DLP: {result.reason}",
|
|
||||||
**resp_ctx,
|
|
||||||
})
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
|
||||||
"""DLP scan on WebSocket frames.
|
|
||||||
|
|
||||||
Outbound frames (from_client) are scanned for credential leakage;
|
|
||||||
inbound frames are scanned for prompt injection. On a block the
|
|
||||||
entire connection is killed — there is no HTTP response surface to
|
|
||||||
write to after the upgrade.
|
|
||||||
"""
|
|
||||||
if flow.websocket is None: # type: ignore[union-attr]
|
|
||||||
return
|
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
|
||||||
if route is None:
|
|
||||||
return
|
|
||||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
|
||||||
content = message.content.decode("utf-8", errors="replace")
|
|
||||||
if message.from_client:
|
|
||||||
result = scan_outbound(route, content, os.environ)
|
|
||||||
if result is not None and result.severity == "block":
|
|
||||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
|
||||||
flow.kill() # type: ignore[union-attr]
|
|
||||||
else:
|
|
||||||
result = scan_inbound(route, content)
|
|
||||||
if result is not None:
|
|
||||||
if result.severity == "block":
|
|
||||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
|
||||||
flow.kill() # type: ignore[union-attr]
|
|
||||||
elif result.severity == "warn":
|
|
||||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
|
||||||
|
|
||||||
|
|
||||||
addons = [EgressAddon()]
|
|
||||||
@@ -1,776 +0,0 @@
|
|||||||
"""Pure logic for the egress mitmproxy addon (PRD 0017, PRD 0053).
|
|
||||||
|
|
||||||
Split out of `egress_addon.py` so the host's unit tests can
|
|
||||||
exercise the parse + decision functions without depending on the
|
|
||||||
`mitmproxy` package. The companion module wraps these with the
|
|
||||||
`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar
|
|
||||||
container.
|
|
||||||
|
|
||||||
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
|
||||||
ships flat into the sidecar bundle image alongside this file —
|
|
||||||
see `Dockerfile.sidecars`)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import typing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
try:
|
|
||||||
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Match types (Gateway API HTTPRoute vocabulary, PRD 0053)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
|
|
||||||
HEADER_MATCH_TYPES = ("exact", "regex")
|
|
||||||
|
|
||||||
VALID_METHODS = frozenset({
|
|
||||||
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
|
|
||||||
"CONNECT",
|
|
||||||
})
|
|
||||||
|
|
||||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
|
||||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PathMatch:
|
|
||||||
type: str # "exact" | "prefix" | "regex"
|
|
||||||
value: str
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HeaderMatch:
|
|
||||||
name: str
|
|
||||||
value: str
|
|
||||||
type: str = "exact" # "exact" | "regex"
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MatchEntry:
|
|
||||||
paths: tuple[PathMatch, ...] = ()
|
|
||||||
methods: tuple[str, ...] = ()
|
|
||||||
headers: tuple[HeaderMatch, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Route:
|
|
||||||
host: str
|
|
||||||
matches: tuple[MatchEntry, ...] = ()
|
|
||||||
auth_scheme: str = ""
|
|
||||||
token_env: str = ""
|
|
||||||
git_fetch: bool = False
|
|
||||||
outbound_detectors: tuple[str, ...] | None = None
|
|
||||||
inbound_detectors: tuple[str, ...] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
LOG_OFF = 0 # no logging
|
|
||||||
LOG_BLOCKS = 1 # log block/warn events with request context
|
|
||||||
LOG_FULL = 2 # log block/warn events + full request and response bodies
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Config:
|
|
||||||
routes: tuple[Route, ...]
|
|
||||||
log: int = LOG_OFF
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Decision:
|
|
||||||
action: str # "forward" or "block"
|
|
||||||
reason: str = ""
|
|
||||||
inject_authorization: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScanResult:
|
|
||||||
severity: str # "block" or "warn"
|
|
||||||
reason: str
|
|
||||||
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
|
||||||
context: str = "" # surrounding text with the match replaced by REDACT
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Parsing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _parse_path_match(idx: int, j: int, raw: object) -> PathMatch:
|
|
||||||
label = f"route[{idx}] matches paths[{j}]"
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"{label}: must be an object")
|
|
||||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
|
||||||
ptype = raw_dict.get("type", "prefix")
|
|
||||||
if not isinstance(ptype, str) or ptype not in PATH_MATCH_TYPES:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: 'type' must be one of {', '.join(PATH_MATCH_TYPES)} "
|
|
||||||
f"(got {ptype!r})"
|
|
||||||
)
|
|
||||||
value = raw_dict.get("value")
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ValueError(f"{label}: 'value' must be a non-empty string")
|
|
||||||
if ptype in ("exact", "prefix") and not value.startswith("/"):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: value {value!r} must start with '/' for "
|
|
||||||
f"type {ptype!r}"
|
|
||||||
)
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
if ptype == "regex":
|
|
||||||
try:
|
|
||||||
compiled = re.compile(value)
|
|
||||||
except re.error as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: regex {value!r} failed to compile: {e}"
|
|
||||||
) from e
|
|
||||||
for k in raw_dict:
|
|
||||||
if k not in ("type", "value"):
|
|
||||||
raise ValueError(f"{label}: unknown key {k!r}")
|
|
||||||
return PathMatch(type=ptype, value=value, compiled=compiled)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_header_match(idx: int, j: int, raw: object) -> HeaderMatch:
|
|
||||||
label = f"route[{idx}] matches headers[{j}]"
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"{label}: must be an object")
|
|
||||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
|
||||||
name = raw_dict.get("name")
|
|
||||||
if not isinstance(name, str) or not name:
|
|
||||||
raise ValueError(f"{label}: 'name' must be a non-empty string")
|
|
||||||
value = raw_dict.get("value")
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ValueError(f"{label}: 'value' must be a string")
|
|
||||||
htype = raw_dict.get("type", "exact")
|
|
||||||
if not isinstance(htype, str) or htype not in HEADER_MATCH_TYPES:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: 'type' must be one of {', '.join(HEADER_MATCH_TYPES)} "
|
|
||||||
f"(got {htype!r})"
|
|
||||||
)
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
if htype == "regex":
|
|
||||||
try:
|
|
||||||
compiled = re.compile(value)
|
|
||||||
except re.error as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: regex {value!r} failed to compile: {e}"
|
|
||||||
) from e
|
|
||||||
for k in raw_dict:
|
|
||||||
if k not in ("name", "value", "type"):
|
|
||||||
raise ValueError(f"{label}: unknown key {k!r}")
|
|
||||||
return HeaderMatch(name=name, value=value, type=htype, compiled=compiled)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_match_entry(idx: int, k: int, raw: object) -> MatchEntry:
|
|
||||||
label = f"route[{idx}] matches[{k}]"
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"{label}: must be an object")
|
|
||||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
|
||||||
|
|
||||||
paths: tuple[PathMatch, ...] = ()
|
|
||||||
paths_raw = raw_dict.get("paths")
|
|
||||||
if paths_raw is not None:
|
|
||||||
if not isinstance(paths_raw, list):
|
|
||||||
raise ValueError(f"{label}: 'paths' must be a list")
|
|
||||||
paths_list = typing.cast(list[object], paths_raw)
|
|
||||||
paths = tuple(_parse_path_match(idx, j, p) for j, p in enumerate(paths_list))
|
|
||||||
|
|
||||||
methods: tuple[str, ...] = ()
|
|
||||||
methods_raw = raw_dict.get("methods")
|
|
||||||
if methods_raw is not None:
|
|
||||||
if not isinstance(methods_raw, list):
|
|
||||||
raise ValueError(f"{label}: 'methods' must be a list")
|
|
||||||
methods_list = typing.cast(list[object], methods_raw)
|
|
||||||
normalised: list[str] = []
|
|
||||||
for j, m in enumerate(methods_list):
|
|
||||||
if not isinstance(m, str):
|
|
||||||
raise ValueError(f"{label}: methods[{j}] must be a string")
|
|
||||||
upper = m.upper()
|
|
||||||
if upper not in VALID_METHODS:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: methods[{j}] {m!r} is not a valid HTTP method"
|
|
||||||
)
|
|
||||||
normalised.append(upper)
|
|
||||||
methods = tuple(normalised)
|
|
||||||
|
|
||||||
headers: tuple[HeaderMatch, ...] = ()
|
|
||||||
headers_raw = raw_dict.get("headers")
|
|
||||||
if headers_raw is not None:
|
|
||||||
if not isinstance(headers_raw, list):
|
|
||||||
raise ValueError(f"{label}: 'headers' must be a list")
|
|
||||||
headers_list = typing.cast(list[object], headers_raw)
|
|
||||||
headers = tuple(
|
|
||||||
_parse_header_match(idx, j, h) for j, h in enumerate(headers_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in raw_dict:
|
|
||||||
if key not in ("paths", "methods", "headers"):
|
|
||||||
raise ValueError(f"{label}: unknown key {key!r}")
|
|
||||||
|
|
||||||
return MatchEntry(paths=paths, methods=methods, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_detectors(
|
|
||||||
idx: int,
|
|
||||||
host: str,
|
|
||||||
raw_dict: dict[str, object],
|
|
||||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None]:
|
|
||||||
"""Parse the optional `dlp` block on a route, returning
|
|
||||||
(outbound_detectors, inbound_detectors)."""
|
|
||||||
dlp_raw = raw_dict.get("dlp")
|
|
||||||
if dlp_raw is None:
|
|
||||||
return None, None
|
|
||||||
label = f"route[{idx}] ({host})"
|
|
||||||
if not isinstance(dlp_raw, dict):
|
|
||||||
raise ValueError(f"{label}: 'dlp' must be an object")
|
|
||||||
dlp = typing.cast(dict[str, object], dlp_raw)
|
|
||||||
|
|
||||||
def _parse_detector_field(
|
|
||||||
field: str,
|
|
||||||
valid_names: frozenset[str],
|
|
||||||
) -> tuple[str, ...] | None:
|
|
||||||
val = dlp.get(field)
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
if val is False:
|
|
||||||
return ()
|
|
||||||
if not isinstance(val, list):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: dlp.{field} must be false, a list, or omitted"
|
|
||||||
)
|
|
||||||
items = typing.cast(list[object], val)
|
|
||||||
names: list[str] = []
|
|
||||||
for j, item in enumerate(items):
|
|
||||||
if not isinstance(item, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: dlp.{field}[{j}] must be a string"
|
|
||||||
)
|
|
||||||
if item not in valid_names:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: dlp.{field}[{j}] {item!r} is not a valid "
|
|
||||||
f"detector name; valid names: {', '.join(sorted(valid_names))}"
|
|
||||||
)
|
|
||||||
names.append(item)
|
|
||||||
return tuple(names)
|
|
||||||
|
|
||||||
outbound = _parse_detector_field("outbound_detectors", OUTBOUND_DETECTOR_NAMES)
|
|
||||||
inbound = _parse_detector_field("inbound_detectors", INBOUND_DETECTOR_NAMES)
|
|
||||||
|
|
||||||
for k in dlp:
|
|
||||||
if k not in ("outbound_detectors", "inbound_detectors"):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: dlp has unknown key {k!r}; accepted keys "
|
|
||||||
f"are 'outbound_detectors', 'inbound_detectors'"
|
|
||||||
)
|
|
||||||
return outbound, inbound
|
|
||||||
|
|
||||||
|
|
||||||
def parse_routes(payload: object) -> tuple[Route, ...]:
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError("routes payload: top-level must be an object")
|
|
||||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
|
||||||
raw: object = payload_dict.get("routes")
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
raise ValueError("routes payload: 'routes' must be a list")
|
|
||||||
raw_list: list[object] = typing.cast(list[object], raw)
|
|
||||||
out: list[Route] = []
|
|
||||||
for i, r in enumerate(raw_list):
|
|
||||||
out.append(_parse_one(i, r))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_one(idx: int, raw: object) -> Route:
|
|
||||||
label = f"route[{idx}]"
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
|
||||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
|
||||||
host: object = raw_dict.get("host")
|
|
||||||
if not isinstance(host, str) or not host:
|
|
||||||
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
|
||||||
|
|
||||||
# matches
|
|
||||||
matches: tuple[MatchEntry, ...] = ()
|
|
||||||
matches_raw = raw_dict.get("matches")
|
|
||||||
if matches_raw is not None:
|
|
||||||
if not isinstance(matches_raw, list):
|
|
||||||
raise ValueError(f"{label} ({host}): 'matches' must be a list")
|
|
||||||
matches_list = typing.cast(list[object], matches_raw)
|
|
||||||
matches = tuple(
|
|
||||||
_parse_match_entry(idx, k, m) for k, m in enumerate(matches_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
# auth (unchanged wire format)
|
|
||||||
auth_scheme: object = raw_dict.get("auth_scheme", "")
|
|
||||||
token_env: object = raw_dict.get("token_env", "")
|
|
||||||
if not isinstance(auth_scheme, str):
|
|
||||||
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
|
||||||
if not isinstance(token_env, str):
|
|
||||||
raise ValueError(f"{label} ({host}): 'token_env' must be a string")
|
|
||||||
if bool(auth_scheme) != bool(token_env):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label} ({host}): 'auth_scheme' and 'token_env' must be both "
|
|
||||||
f"set or both empty (got auth_scheme={auth_scheme!r}, "
|
|
||||||
f"token_env={token_env!r})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# git-over-HTTPS policy
|
|
||||||
git_fetch = False
|
|
||||||
git_raw = raw_dict.get("git")
|
|
||||||
if git_raw is not None:
|
|
||||||
if not isinstance(git_raw, dict):
|
|
||||||
raise ValueError(f"{label} ({host}): 'git' must be an object")
|
|
||||||
git_dict: dict[str, object] = typing.cast(dict[str, object], git_raw)
|
|
||||||
fetch_raw = git_dict.get("fetch", False)
|
|
||||||
if fetch_raw is True or fetch_raw is False:
|
|
||||||
git_fetch = fetch_raw
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{label} ({host}): 'git.fetch' must be a boolean")
|
|
||||||
for k in git_dict:
|
|
||||||
if k != "fetch":
|
|
||||||
raise ValueError(
|
|
||||||
f"{label} ({host}): git has unknown key {k!r}; "
|
|
||||||
"accepted key is 'fetch'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# dlp detectors
|
|
||||||
outbound_detectors, inbound_detectors = _parse_detectors(
|
|
||||||
idx, host, raw_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
for k in raw_dict:
|
|
||||||
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp", "git"):
|
|
||||||
raise ValueError(
|
|
||||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
|
||||||
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp', 'git'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Route(
|
|
||||||
host=host,
|
|
||||||
matches=matches,
|
|
||||||
auth_scheme=auth_scheme,
|
|
||||||
token_env=token_env,
|
|
||||||
git_fetch=git_fetch,
|
|
||||||
outbound_detectors=outbound_detectors,
|
|
||||||
inbound_detectors=inbound_detectors,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {"value": pm.value}
|
|
||||||
if pm.type != "prefix":
|
|
||||||
d["type"] = pm.type
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
|
||||||
if hm.type != "exact":
|
|
||||||
d["type"] = hm.type
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {}
|
|
||||||
if me.paths:
|
|
||||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
|
||||||
if me.methods:
|
|
||||||
d["methods"] = list(me.methods)
|
|
||||||
if me.headers:
|
|
||||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
|
||||||
"""Serialize a Route to YAML-schema-compatible dict.
|
|
||||||
|
|
||||||
Uses the same field names the YAML parser accepts, so the output
|
|
||||||
can be round-tripped directly into an `allow` or `egress-block`
|
|
||||||
proposal without translation. Fields that are empty/default are
|
|
||||||
omitted so the agent doesn't copy irrelevant keys."""
|
|
||||||
d: dict[str, object] = {"host": r.host}
|
|
||||||
if r.auth_scheme:
|
|
||||||
d["auth_scheme"] = r.auth_scheme
|
|
||||||
d["token_env"] = r.token_env
|
|
||||||
if r.matches:
|
|
||||||
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
|
||||||
if r.git_fetch:
|
|
||||||
d["git"] = {"fetch": True}
|
|
||||||
dlp: dict[str, object] = {}
|
|
||||||
if r.outbound_detectors is not None:
|
|
||||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
|
||||||
if r.inbound_detectors is not None:
|
|
||||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
|
||||||
if dlp:
|
|
||||||
d["dlp"] = dlp
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def load_routes(text: str) -> tuple[Route, ...]:
|
|
||||||
"""Parse YAML text → routes."""
|
|
||||||
try:
|
|
||||||
payload = parse_yaml_subset(text)
|
|
||||||
except YamlSubsetError as e:
|
|
||||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
|
||||||
return parse_routes(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_config(payload: object) -> "Config":
|
|
||||||
"""Parse a full egress config payload (top-level log level + routes)."""
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError("routes payload: top-level must be an object")
|
|
||||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
|
||||||
|
|
||||||
log_raw: object = payload_dict.get("log", LOG_OFF)
|
|
||||||
if log_raw is True or log_raw is False or not isinstance(log_raw, int) \
|
|
||||||
or log_raw not in (LOG_OFF, LOG_BLOCKS, LOG_FULL):
|
|
||||||
raise ValueError(
|
|
||||||
f"routes payload: 'log' must be {LOG_OFF}, {LOG_BLOCKS}, or {LOG_FULL}"
|
|
||||||
)
|
|
||||||
|
|
||||||
routes = parse_routes(payload)
|
|
||||||
return Config(routes=routes, log=log_raw)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(text: str) -> "Config":
|
|
||||||
"""Parse YAML text → Config (routes + log flag)."""
|
|
||||||
try:
|
|
||||||
payload = parse_yaml_subset(text)
|
|
||||||
except YamlSubsetError as e:
|
|
||||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
|
||||||
return parse_config(payload)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Match evaluation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _path_matches(pm: PathMatch, request_path: str) -> bool:
|
|
||||||
if pm.type == "exact":
|
|
||||||
return request_path == pm.value
|
|
||||||
if pm.type == "prefix":
|
|
||||||
if request_path == pm.value:
|
|
||||||
return True
|
|
||||||
if not pm.value.endswith("/"):
|
|
||||||
return request_path.startswith(pm.value + "/")
|
|
||||||
return request_path.startswith(pm.value)
|
|
||||||
if pm.type == "regex" and pm.compiled is not None:
|
|
||||||
return pm.compiled.search(request_path) is not None
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_matches(
|
|
||||||
entry: MatchEntry,
|
|
||||||
request_path: str,
|
|
||||||
request_method: str,
|
|
||||||
request_headers: typing.Mapping[str, str],
|
|
||||||
) -> bool:
|
|
||||||
"""All predicates within a MatchEntry are ANDed."""
|
|
||||||
if entry.paths:
|
|
||||||
if not any(_path_matches(pm, request_path) for pm in entry.paths):
|
|
||||||
return False
|
|
||||||
if entry.methods:
|
|
||||||
if request_method.upper() not in entry.methods:
|
|
||||||
return False
|
|
||||||
if entry.headers:
|
|
||||||
for hm in entry.headers:
|
|
||||||
header_val = request_headers.get(hm.name.lower())
|
|
||||||
if header_val is None:
|
|
||||||
return False
|
|
||||||
if hm.type == "exact":
|
|
||||||
if header_val != hm.value:
|
|
||||||
return False
|
|
||||||
elif hm.type == "regex" and hm.compiled is not None:
|
|
||||||
if not hm.compiled.search(header_val):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_matches(
|
|
||||||
route: Route,
|
|
||||||
request_path: str,
|
|
||||||
request_method: str = "GET",
|
|
||||||
request_headers: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if the request matches this route's match entries.
|
|
||||||
Empty matches tuple means all requests match (bare-pass route)."""
|
|
||||||
if not route.matches:
|
|
||||||
return True
|
|
||||||
hdrs: typing.Mapping[str, str] = request_headers or {}
|
|
||||||
return any(
|
|
||||||
_entry_matches(entry, request_path, request_method, hdrs)
|
|
||||||
for entry in route.matches
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Git push detection (unchanged)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_git_push_request(path: str, query: str) -> bool:
|
|
||||||
if path.endswith("/git-receive-pack"):
|
|
||||||
return True
|
|
||||||
if path.endswith("/info/refs"):
|
|
||||||
for pair in query.split("&"):
|
|
||||||
k, _, v = pair.partition("=")
|
|
||||||
if k == "service" and v == "git-receive-pack":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_git_fetch_request(path: str, query: str) -> bool:
|
|
||||||
if path.endswith("/git-upload-pack"):
|
|
||||||
return True
|
|
||||||
if path.endswith("/info/refs"):
|
|
||||||
for pair in query.split("&"):
|
|
||||||
k, _, v = pair.partition("=")
|
|
||||||
if k == "service" and v == "git-upload-pack":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Route lookup + decision
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def match_route(
|
|
||||||
routes: typing.Sequence[Route],
|
|
||||||
request_host: str,
|
|
||||||
) -> Route | None:
|
|
||||||
target = request_host.lower()
|
|
||||||
for r in routes:
|
|
||||||
if r.host.lower() == target:
|
|
||||||
return r
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def decide(
|
|
||||||
routes: typing.Sequence[Route],
|
|
||||||
request_host: str,
|
|
||||||
request_path: str,
|
|
||||||
environ: typing.Mapping[str, str],
|
|
||||||
*,
|
|
||||||
request_method: str = "GET",
|
|
||||||
request_headers: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> Decision:
|
|
||||||
route = match_route(routes, request_host)
|
|
||||||
if route is None:
|
|
||||||
return Decision(
|
|
||||||
action="block",
|
|
||||||
reason=(
|
|
||||||
f"egress: host {request_host!r} is not in the "
|
|
||||||
f"bottle's egress.routes allowlist. Declare a "
|
|
||||||
f"route for it or remove the request."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not evaluate_matches(route, request_path, request_method, request_headers):
|
|
||||||
return Decision(
|
|
||||||
action="block",
|
|
||||||
reason=(
|
|
||||||
f"egress: request {request_method} {request_path!r} "
|
|
||||||
f"does not match any entry in matches for "
|
|
||||||
f"{route.host!r}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if route.auth_scheme and route.token_env:
|
|
||||||
token = environ.get(route.token_env, "")
|
|
||||||
if not token:
|
|
||||||
return Decision(
|
|
||||||
action="block",
|
|
||||||
reason=(
|
|
||||||
f"egress: route for {route.host!r} declared auth "
|
|
||||||
f"but env var {route.token_env!r} is unset"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Decision(
|
|
||||||
action="forward",
|
|
||||||
inject_authorization=f"{route.auth_scheme} {token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Decision(action="forward")
|
|
||||||
|
|
||||||
|
|
||||||
def decide_git_fetch(
|
|
||||||
routes: typing.Sequence[Route],
|
|
||||||
request_host: str,
|
|
||||||
) -> Decision:
|
|
||||||
route = match_route(routes, request_host)
|
|
||||||
if route is not None and route.git_fetch:
|
|
||||||
return Decision(action="forward")
|
|
||||||
return Decision(
|
|
||||||
action="block",
|
|
||||||
reason=(
|
|
||||||
"egress: git fetch/clone over HTTPS is not allowed by default; "
|
|
||||||
"use git-gate for declared repos or set "
|
|
||||||
"egress.routes[].git.fetch=true for explicit read-only "
|
|
||||||
"HTTPS Git access."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DLP scan dispatch (PRD 0053)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_outbound_scan_text(
|
|
||||||
host: str,
|
|
||||||
path: str,
|
|
||||||
query: str,
|
|
||||||
headers: typing.Mapping[str, str],
|
|
||||||
body: str,
|
|
||||||
) -> str:
|
|
||||||
"""Assemble all outbound request surfaces into one string for DLP scanning.
|
|
||||||
|
|
||||||
Covers hostname (DNS tunnelling), path, query params, all headers, body.
|
|
||||||
"""
|
|
||||||
parts: list[str] = [host, path]
|
|
||||||
if query:
|
|
||||||
parts.append(query)
|
|
||||||
for name, value in headers.items():
|
|
||||||
parts.append(f"{name}: {value}")
|
|
||||||
if body:
|
|
||||||
parts.append(body)
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def outbound_scan_headers(
|
|
||||||
route: Route,
|
|
||||||
headers: typing.Mapping[str, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Return request headers that should be included in outbound DLP.
|
|
||||||
|
|
||||||
Routes that inject sidecar-owned auth always strip the agent's
|
|
||||||
Authorization header before forwarding. Scanning that header first
|
|
||||||
creates false positives for provider clients that insist on sending
|
|
||||||
their own bearer-shaped placeholder, while still not changing what
|
|
||||||
reaches the upstream.
|
|
||||||
"""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
skip_auth = bool(route.auth_scheme and route.token_env)
|
|
||||||
for name, value in headers.items():
|
|
||||||
if skip_auth and name.lower() == "authorization":
|
|
||||||
continue
|
|
||||||
out[name] = value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_inbound_scan_text(
|
|
||||||
headers: typing.Mapping[str, str],
|
|
||||||
body: str,
|
|
||||||
) -> str:
|
|
||||||
"""Assemble inbound response surfaces into one string for DLP scanning.
|
|
||||||
|
|
||||||
Covers all response headers plus body.
|
|
||||||
"""
|
|
||||||
parts: list[str] = []
|
|
||||||
for name, value in headers.items():
|
|
||||||
parts.append(f"{name}: {value}")
|
|
||||||
if body:
|
|
||||||
parts.append(body)
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _detector_enabled(
|
|
||||||
configured: tuple[str, ...] | None,
|
|
||||||
name: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a named detector is enabled for a route direction.
|
|
||||||
None means all enabled; empty tuple means all disabled."""
|
|
||||||
if configured is None:
|
|
||||||
return True
|
|
||||||
return name in configured
|
|
||||||
|
|
||||||
|
|
||||||
def scan_outbound(
|
|
||||||
route: Route,
|
|
||||||
body: str | bytes,
|
|
||||||
environ: typing.Mapping[str, str],
|
|
||||||
) -> ScanResult | None:
|
|
||||||
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
|
||||||
# at import time (the sidecar copies it flat alongside this file).
|
|
||||||
try:
|
|
||||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
|
||||||
scan_crlf_injection,
|
|
||||||
scan_known_secrets,
|
|
||||||
scan_token_patterns,
|
|
||||||
)
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .dlp_detectors import ( # type: ignore[import-not-found]
|
|
||||||
scan_crlf_injection,
|
|
||||||
scan_known_secrets,
|
|
||||||
scan_token_patterns,
|
|
||||||
)
|
|
||||||
|
|
||||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
# CRLF injection is never legitimate — runs unconditionally, not gated
|
|
||||||
# by outbound_detectors config.
|
|
||||||
result = scan_crlf_injection(text)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
if _detector_enabled(route.outbound_detectors, "token_patterns"):
|
|
||||||
result = scan_token_patterns(text, location="body")
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
if _detector_enabled(route.outbound_detectors, "known_secrets"):
|
|
||||||
result = scan_known_secrets(text, location="body", env=environ)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def scan_inbound(
|
|
||||||
route: Route,
|
|
||||||
body: str | bytes,
|
|
||||||
) -> ScanResult | None:
|
|
||||||
try:
|
|
||||||
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
|
||||||
|
|
||||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if _detector_enabled(route.inbound_detectors, "naive_injection_detection"):
|
|
||||||
result = scan_naive_injection(text)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LOG_BLOCKS",
|
|
||||||
"route_to_yaml_dict",
|
|
||||||
"LOG_FULL",
|
|
||||||
"LOG_OFF",
|
|
||||||
"Config",
|
|
||||||
"Decision",
|
|
||||||
"HeaderMatch",
|
|
||||||
"MatchEntry",
|
|
||||||
"PathMatch",
|
|
||||||
"Route",
|
|
||||||
"ScanResult",
|
|
||||||
"build_inbound_scan_text",
|
|
||||||
"build_outbound_scan_text",
|
|
||||||
"decide",
|
|
||||||
"decide_git_fetch",
|
|
||||||
"evaluate_matches",
|
|
||||||
"is_git_push_request",
|
|
||||||
"is_git_fetch_request",
|
|
||||||
"load_config",
|
|
||||||
"load_routes",
|
|
||||||
"match_route",
|
|
||||||
"outbound_scan_headers",
|
|
||||||
"parse_config",
|
|
||||||
"parse_routes",
|
|
||||||
"scan_inbound",
|
|
||||||
"scan_outbound",
|
|
||||||
]
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
|
|
||||||
#
|
|
||||||
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
|
||||||
# ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
|
|
||||||
# call it as a normal child. Behavior is unchanged:
|
|
||||||
#
|
|
||||||
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
|
||||||
# to `--mode upstream:URL` to chain through an upstream proxy.
|
|
||||||
# mitmproxy does NOT honor HTTPS_PROXY on its outbound side,
|
|
||||||
# so the upstream wiring has to be the mitmproxy mode flag,
|
|
||||||
# not env.
|
|
||||||
# * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a
|
|
||||||
# combined trust bundle (system roots + upstream CA) and point
|
|
||||||
# mitmproxy at it. The option REPLACES mitmproxy's default
|
|
||||||
# trust store, so passing the upstream CA alone would break
|
|
||||||
# non-chained hosts.
|
|
||||||
# * `-s /app/egress_addon.py` loads the addon that reads
|
|
||||||
# /etc/egress/routes.yaml.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Pin mitmproxy's config dir to the bind-mount location of its CA
|
|
||||||
# regardless of which user mitmdump runs as. In the legacy
|
|
||||||
# four-sidecar setup (Dockerfile.egress, USER mitmproxy) this
|
|
||||||
# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024
|
|
||||||
# bundle (USER root) `~root/.mitmproxy` is empty, so without this
|
|
||||||
# flag mitmdump would generate a fresh CA on the wrong path and
|
|
||||||
# the agent's installed trust anchor would no longer match the
|
|
||||||
# bumped leaf certs.
|
|
||||||
CONFDIR=/home/mitmproxy/.mitmproxy
|
|
||||||
CONFDIR_FLAG="--set confdir=$CONFDIR"
|
|
||||||
|
|
||||||
MODE="--mode regular@9099"
|
|
||||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
|
||||||
MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress
|
|
||||||
# directly via the docker network alias). Smolmachines backend
|
|
||||||
# uses EGRESS_LISTEN_HOST when a non-default binding is needed.
|
|
||||||
LISTEN_HOST_FLAG=""
|
|
||||||
if [ -n "$EGRESS_LISTEN_HOST" ]; then
|
|
||||||
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
TRUST_FLAG=""
|
|
||||||
if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
|
|
||||||
COMBINED=$CONFDIR/combined-trust.pem
|
|
||||||
cat /etc/ssl/certs/ca-certificates.crt "$EGRESS_UPSTREAM_CA" > "$COMBINED"
|
|
||||||
TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Scope the proxy env to this process tree only. In the bundle
|
|
||||||
# image (PRD 0024) multiple daemons share one container — setting
|
|
||||||
# HTTPS_PROXY at the container level would route git-gate's git
|
|
||||||
# pushes through an upstream proxy unintentionally. Setting them
|
|
||||||
# here means only mitmdump's subprocess inherits them.
|
|
||||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
|
||||||
export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY"
|
|
||||||
export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY"
|
|
||||||
export NO_PROXY="localhost,127.0.0.1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
"""Per-agent git-gate (PRD 0008).
|
|
||||||
|
|
||||||
A third per-agent sidecar that fronts the bottle's declared git
|
|
||||||
upstreams as a transparent mirror. Each `bottle.git` entry maps to
|
|
||||||
a bare repo on the gate; `git daemon` serves the bare repos over
|
|
||||||
`git://<gate>/<name>.git`. Two hooks make the mirror bidirectional:
|
|
||||||
|
|
||||||
- **`pre-receive`** (push path) — gitleaks-scans incoming refs and,
|
|
||||||
on clean, forwards them to the real upstream with the
|
|
||||||
gate-resident credential.
|
|
||||||
- **`--access-hook`** (fetch path) — runs `git fetch origin --prune`
|
|
||||||
against the real upstream before every `upload-pack`, so an
|
|
||||||
agent fetch returns whatever the upstream has *now*. Fail-closed
|
|
||||||
if the upstream is unreachable.
|
|
||||||
|
|
||||||
The agent never sees the upstream credential under either path.
|
|
||||||
|
|
||||||
Why a separate sidecar (not folded into egress or ssh-gate): the
|
|
||||||
gate is the only one of the three that holds upstream push
|
|
||||||
credentials. Mixing it with egress would put push creds in the
|
|
||||||
same blast radius as internet-facing TLS interception; mixing it
|
|
||||||
with ssh-gate would force ssh-gate above L4 and into git-protocol
|
|
||||||
land. See `docs/prds/0008-git-gate.md`.
|
|
||||||
|
|
||||||
This module defines the abstract gate (`GitGate`) and its plan
|
|
||||||
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
|
|
||||||
backend-specific and lives on concrete subclasses (see
|
|
||||||
`bot_bottle/backend/docker/git_gate.py`)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
from abc import ABC
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .log import info
|
|
||||||
from .manifest import ManifestBottle, ManifestGitEntry
|
|
||||||
|
|
||||||
|
|
||||||
# Short network alias for git-gate inside the sidecar bundle. The
|
|
||||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
|
||||||
GIT_GATE_HOSTNAME = "git-gate"
|
|
||||||
# Bound half-open git client sessions. If an agent/tool runner is
|
|
||||||
# interrupted during push, git daemon should reap the receive-pack
|
|
||||||
# child instead of keeping the gate wedged indefinitely.
|
|
||||||
GIT_GATE_DAEMON_TIMEOUT_SECS = 15
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GitGateUpstream:
|
|
||||||
"""One bare repo on the gate. `name` drives the bare-repo path
|
|
||||||
(`/git/<name>.git`), the agent's URL after insteadOf rewrite
|
|
||||||
(`git://<gate>/<name>.git`), and the per-upstream credential
|
|
||||||
paths inside the gate (`/git-gate/creds/<name>-key` and
|
|
||||||
`/git-gate/creds/<name>-known_hosts`).
|
|
||||||
|
|
||||||
`identity_file` is the host-side absolute path the gate's start
|
|
||||||
step will docker-cp into the container. `known_host_key` is the
|
|
||||||
KnownHostKey string from the manifest; the gate's start step
|
|
||||||
materialises it into a known_hosts file if non-empty.
|
|
||||||
|
|
||||||
the gate credential paths inside the running sidecar."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
upstream_url: str
|
|
||||||
upstream_host: str
|
|
||||||
upstream_port: str
|
|
||||||
identity_file: str
|
|
||||||
known_host_key: str
|
|
||||||
known_hosts_file: Path = Path()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GitGatePlan:
|
|
||||||
"""Output of GitGate.prepare; consumed by .start.
|
|
||||||
|
|
||||||
The script + slug + upstream fields are filled at prepare time
|
|
||||||
(host-side, side-effect-free on docker). The network fields are
|
|
||||||
populated by the backend's launch step via `dataclasses.replace`
|
|
||||||
once those networks exist. Empty defaults are sentinels meaning
|
|
||||||
"not yet set"; `.start` validates that they are populated.
|
|
||||||
|
|
||||||
`hook_script` is the shared `pre-receive` for push-time gating;
|
|
||||||
`access_hook_script` is `git daemon`'s `--access-hook` for the
|
|
||||||
fetch-time upstream refresh."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
entrypoint_script: Path
|
|
||||||
hook_script: Path
|
|
||||||
access_hook_script: Path
|
|
||||||
upstreams: tuple[GitGateUpstream, ...]
|
|
||||||
internal_network: str = ""
|
|
||||||
egress_network: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstream, ...]:
|
|
||||||
"""Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name
|
|
||||||
validation already ran in `manifest.ManifestBottle.from_dict`."""
|
|
||||||
return tuple(
|
|
||||||
GitGateUpstream(
|
|
||||||
name=e.Name,
|
|
||||||
upstream_url=e.Upstream,
|
|
||||||
upstream_host=e.UpstreamHost,
|
|
||||||
upstream_port=e.UpstreamPort,
|
|
||||||
identity_file=e.IdentityFile,
|
|
||||||
known_host_key=e.KnownHostKey,
|
|
||||||
)
|
|
||||||
for e in bottle.git
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_render_gitconfig(
|
|
||||||
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
|
||||||
) -> str:
|
|
||||||
"""Render the agent's ~/.gitconfig content for git-gate
|
|
||||||
`insteadOf` rewrites. Pure host-side, no docker / smolvm;
|
|
||||||
exposed for tests + reuse across backends.
|
|
||||||
|
|
||||||
`gate_host` is the part of the URL between `<scheme>://` and the
|
|
||||||
repo path — backends differ here:
|
|
||||||
- docker: `git-gate` (the short network alias)
|
|
||||||
- smolmachines: `<bundle_ip>:<port>` (no DNS in the
|
|
||||||
TSI-allowlisted guest)
|
|
||||||
|
|
||||||
Empty `entries` returns an empty string so callers can no-op
|
|
||||||
cleanly without conditional formatting at the call site."""
|
|
||||||
if not entries:
|
|
||||||
return ""
|
|
||||||
out = [
|
|
||||||
"# bot-bottle git-gate (PRD 0008): every git operation against\n",
|
|
||||||
"# a declared upstream routes through the gate, which mirrors\n",
|
|
||||||
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
|
||||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
|
||||||
]
|
|
||||||
for entry in entries:
|
|
||||||
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
|
||||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
|
||||||
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
|
|
||||||
port = (
|
|
||||||
f":{entry.UpstreamPort}"
|
|
||||||
if entry.UpstreamPort and entry.UpstreamPort != "22"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
alias = (
|
|
||||||
f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/"
|
|
||||||
f"{entry.UpstreamPath}"
|
|
||||||
)
|
|
||||||
out.append(f"\tinsteadOf = {alias}\n")
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
|
|
||||||
"""Format `host[:port] key` for OpenSSH's known_hosts. Non-default
|
|
||||||
ports use the bracketed `[host]:port` form (the form OpenSSH writes
|
|
||||||
on disk for hosts reached via a non-22 port)."""
|
|
||||||
if port and port != "22":
|
|
||||||
target = f"[{host}]:{port}"
|
|
||||||
else:
|
|
||||||
target = host
|
|
||||||
return f"{target} {key}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
|
||||||
"""Posix-sh entrypoint. One `init_repo` call per upstream, then
|
|
||||||
`exec git daemon`. The function reads
|
|
||||||
`/git-gate/creds/<name>-{key,known_hosts}` (bind-mounted into
|
|
||||||
the bundle by the renderer) and wires them into each bare repo's
|
|
||||||
config; the access-hook + pre-receive hook pick those paths up
|
|
||||||
at fetch / push time."""
|
|
||||||
lines = [
|
|
||||||
"#!/bin/sh",
|
|
||||||
"set -eu",
|
|
||||||
"",
|
|
||||||
"init_repo() {",
|
|
||||||
" name=$1",
|
|
||||||
" upstream_url=$2",
|
|
||||||
" keyfile=/git-gate/creds/${name}-key",
|
|
||||||
" hostsfile=/git-gate/creds/${name}-known_hosts",
|
|
||||||
"",
|
|
||||||
# `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the
|
|
||||||
# host, so chmod-syscalls fail with EROFS. The files already
|
|
||||||
# have the right perms on the host (SSH requires 0600 to load
|
|
||||||
# the key in the first place), so the chmod is best-effort
|
|
||||||
# cleanup for the legacy docker-cp path where the file
|
|
||||||
# landed at the host's umask perms.
|
|
||||||
" chmod 600 \"$keyfile\" 2>/dev/null || true",
|
|
||||||
" if [ -f \"$hostsfile\" ]; then",
|
|
||||||
" chmod 600 \"$hostsfile\" 2>/dev/null || true",
|
|
||||||
" fi",
|
|
||||||
"",
|
|
||||||
" repo=/git/${name}.git",
|
|
||||||
" if [ ! -d \"$repo\" ]; then",
|
|
||||||
" git init --bare \"$repo\" >/dev/null",
|
|
||||||
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
|
|
||||||
# a later `git fetch origin` mirrors the upstream's full ref",
|
|
||||||
# graph (heads, tags, notes) into the bare repo at canonical",
|
|
||||||
# paths. It does NOT set remote.origin.mirror=true, so an",
|
|
||||||
# explicit `git push origin <ref>:<ref>` still pushes one ref.",
|
|
||||||
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
|
|
||||||
" fi",
|
|
||||||
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
|
||||||
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
|
|
||||||
" git -C \"$repo\" config receive.denyCurrentBranch ignore",
|
|
||||||
" git -C \"$repo\" config receive.advertisePushOptions true",
|
|
||||||
" git -C \"$repo\" config http.receivepack true",
|
|
||||||
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
|
||||||
"}",
|
|
||||||
"",
|
|
||||||
"mkdir -p /git",
|
|
||||||
]
|
|
||||||
for u in upstreams:
|
|
||||||
lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}")
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
"exec git daemon \\",
|
|
||||||
" --reuseaddr \\",
|
|
||||||
f" --timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
|
||||||
f" --init-timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
|
||||||
" --base-path=/git \\",
|
|
||||||
" --export-all \\",
|
|
||||||
" --enable=receive-pack \\",
|
|
||||||
" --access-hook=/etc/git-gate/access-hook \\",
|
|
||||||
" --verbose",
|
|
||||||
])
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_render_hook() -> str:
|
|
||||||
"""The shared pre-receive hook: gitleaks-scan all incoming refs,
|
|
||||||
then forward each accepted ref to the real upstream (`origin`)
|
|
||||||
using the per-repo credential. Failure in either phase aborts
|
|
||||||
the push so the agent sees a real rejection. POSIX sh.
|
|
||||||
|
|
||||||
Two phases (scan all, then push all) keeps a hit on ref N from
|
|
||||||
half-pushing refs 1..N-1; both phases re-read stdin from a temp
|
|
||||||
file because pre-receive's stdin is a one-shot stream."""
|
|
||||||
return r"""#!/bin/sh
|
|
||||||
# git-gate pre-receive (PRD 0008). Stdin: <old> <new> <ref> per line.
|
|
||||||
set -u
|
|
||||||
|
|
||||||
refs_file=$(mktemp)
|
|
||||||
trap 'rm -f "$refs_file"' EXIT
|
|
||||||
cat > "$refs_file"
|
|
||||||
|
|
||||||
zero=0000000000000000000000000000000000000000
|
|
||||||
|
|
||||||
supervise_gitleaks_allow() {
|
|
||||||
log_opts=$1
|
|
||||||
ref=$2
|
|
||||||
report_file=$(mktemp)
|
|
||||||
if ! gitleaks git \
|
|
||||||
--log-opts="$log_opts" \
|
|
||||||
--no-banner \
|
|
||||||
--redact \
|
|
||||||
--ignore-gitleaks-allow \
|
|
||||||
--report-format=json \
|
|
||||||
--report-path="$report_file" \
|
|
||||||
--exit-code 0 \
|
|
||||||
1>&2; then
|
|
||||||
rm -f "$report_file"
|
|
||||||
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
proposal_id=$(
|
|
||||||
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
report_path = Path(sys.argv[1])
|
|
||||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
|
||||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
|
||||||
if not queue_dir or not slug:
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = json.loads(report_path.read_text() or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
sys.exit(3)
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
sys.exit(3)
|
|
||||||
if not raw:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
|
|
||||||
lines = [
|
|
||||||
"gitleaks inline suppression requires supervisor approval",
|
|
||||||
f"ref: {ref}",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
for i, finding in enumerate(raw, 1):
|
|
||||||
if not isinstance(finding, dict):
|
|
||||||
continue
|
|
||||||
file_path = finding.get("File", "")
|
|
||||||
line_no = finding.get("StartLine", finding.get("Line", ""))
|
|
||||||
rule_id = finding.get("RuleID", "")
|
|
||||||
commit = finding.get("Commit", "")
|
|
||||||
line = finding.get("Line", "")
|
|
||||||
lines.extend([
|
|
||||||
f"finding {i}:",
|
|
||||||
f" file: {file_path}",
|
|
||||||
f" line: {line_no}",
|
|
||||||
f" rule: {rule_id}",
|
|
||||||
f" commit: {commit}",
|
|
||||||
f" code: {line}",
|
|
||||||
"",
|
|
||||||
])
|
|
||||||
|
|
||||||
payload = "\n".join(lines).rstrip() + "\n"
|
|
||||||
proposal_id = str(uuid.uuid4())
|
|
||||||
proposal = {
|
|
||||||
"id": proposal_id,
|
|
||||||
"bottle_slug": slug,
|
|
||||||
"tool": "gitleaks-allow",
|
|
||||||
"proposed_file": payload,
|
|
||||||
"justification": (
|
|
||||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
|
||||||
"approve only for dummy test fixtures or confirmed false positives"
|
|
||||||
),
|
|
||||||
"arrival_timestamp": datetime.datetime.now(
|
|
||||||
datetime.timezone.utc
|
|
||||||
).isoformat(),
|
|
||||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
|
||||||
}
|
|
||||||
queue = Path(queue_dir)
|
|
||||||
queue.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = queue / f"{proposal_id}.proposal.json"
|
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
|
||||||
json.dump(proposal, f, indent=2)
|
|
||||||
f.write("\n")
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
print(proposal_id)
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
rc=$?
|
|
||||||
rm -f "$report_file"
|
|
||||||
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
|
||||||
response_file="$queue_dir/${proposal_id}.response.json"
|
|
||||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
|
||||||
case "$timeout" in
|
|
||||||
''|*[!0-9]*)
|
|
||||||
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
|
|
||||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
|
||||||
waited=0
|
|
||||||
while [ "$waited" -lt "$timeout" ]; do
|
|
||||||
if [ -f "$response_file" ]; then
|
|
||||||
status=$(python3 - "$response_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
sys.exit(1)
|
|
||||||
status = raw.get("status")
|
|
||||||
if not isinstance(status, str):
|
|
||||||
sys.exit(1)
|
|
||||||
print(status)
|
|
||||||
PY
|
|
||||||
) || status=""
|
|
||||||
case "$status" in
|
|
||||||
approved|modified)
|
|
||||||
mkdir -p "$queue_dir/processed"
|
|
||||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
|
||||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
|
||||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
rejected)
|
|
||||||
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
waited=$((waited + 1))
|
|
||||||
done
|
|
||||||
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Phase 1: gitleaks scan each ref's incoming commits.
|
|
||||||
while IFS=' ' read -r old new ref; do
|
|
||||||
[ -z "$ref" ] && continue
|
|
||||||
[ "$new" = "$zero" ] && continue
|
|
||||||
if [ "$old" = "$zero" ]; then
|
|
||||||
# New ref: scan only the commits this push introduces — those
|
|
||||||
# reachable from $new but not from any ref the gate already has.
|
|
||||||
# Everything already on the gate arrived via upstream mirror-fetch
|
|
||||||
# or a previously gitleaks-scanned push, so it's already-upstream
|
|
||||||
# or already-scanned; re-scanning it (the old `$new` full-ancestry
|
|
||||||
# range) only resurfaces historical findings and blocks every new
|
|
||||||
# branch. See PRD 0028 / issue #106.
|
|
||||||
log_opts="$new --not --all"
|
|
||||||
else
|
|
||||||
log_opts="$old..$new"
|
|
||||||
fi
|
|
||||||
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
|
|
||||||
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
|
||||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done < "$refs_file"
|
|
||||||
|
|
||||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
|
||||||
# in the entrypoint via `git remote add --mirror=fetch`).
|
|
||||||
keyfile=$(git config --get git-gate.identityFile)
|
|
||||||
hostsfile=$(git config --get git-gate.knownHosts)
|
|
||||||
if [ ! -f "$hostsfile" ]; then
|
|
||||||
echo "git-gate: no KnownHostKey configured for this upstream; refusing to push" >&2
|
|
||||||
echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
|
|
||||||
|
|
||||||
push_option_count=${GIT_PUSH_OPTION_COUNT:-0}
|
|
||||||
case "$push_option_count" in
|
|
||||||
''|*[!0-9]*)
|
|
||||||
echo "git-gate: invalid GIT_PUSH_OPTION_COUNT=$push_option_count" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
set --
|
|
||||||
i=0
|
|
||||||
while [ "$i" -lt "$push_option_count" ]; do
|
|
||||||
opt=$(printenv "GIT_PUSH_OPTION_$i" || :)
|
|
||||||
set -- "$@" --push-option="$opt"
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
while IFS=' ' read -r old new ref; do
|
|
||||||
[ -z "$ref" ] && continue
|
|
||||||
if [ "$new" = "$zero" ]; then
|
|
||||||
refspec=":$ref"
|
|
||||||
elif [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
|
|
||||||
refspec="+$new:$ref"
|
|
||||||
else
|
|
||||||
refspec="$new:$ref"
|
|
||||||
fi
|
|
||||||
echo "git-gate: forwarding $ref to origin" >&2
|
|
||||||
if ! GIT_SSH_COMMAND="$ssh_cmd" git push "$@" origin "$refspec" 1>&2; then
|
|
||||||
echo "git-gate: upstream push failed for $ref" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done < "$refs_file"
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_render_access_hook() -> str:
|
|
||||||
"""`git daemon --access-hook` script. Runs before each protocol
|
|
||||||
service; for `upload-pack` (fetch / clone / ls-remote / pull) it
|
|
||||||
refreshes the bare repo from upstream first, so the response
|
|
||||||
reflects upstream's current state. For other services (notably
|
|
||||||
`receive-pack`) it returns 0 immediately and lets the existing
|
|
||||||
pre-receive hook gate the operation. POSIX sh.
|
|
||||||
|
|
||||||
The hook receives:
|
|
||||||
$1 service name (`upload-pack`, `receive-pack`, ...)
|
|
||||||
$2 absolute path to the resolved repo
|
|
||||||
$3 client hostname (unused)
|
|
||||||
$4 client tcp address (unused)
|
|
||||||
|
|
||||||
Fail-closed on upstream errors: the agent's fetch fails too,
|
|
||||||
so it never silently sees stale data — matches the PRD's
|
|
||||||
'equivalent to operations against the upstream' contract."""
|
|
||||||
return r"""#!/bin/sh
|
|
||||||
# git-gate access-hook (PRD 0008). $1=service $2=repo $3=host $4=peer
|
|
||||||
set -u
|
|
||||||
service=$1
|
|
||||||
repo_dir=$2
|
|
||||||
|
|
||||||
# Push path keeps its own gating in pre-receive (gitleaks +
|
|
||||||
# forward). Only refresh-from-upstream on fetch operations.
|
|
||||||
if [ "$service" != "upload-pack" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
keyfile=$(git -C "$repo_dir" config --get git-gate.identityFile 2>/dev/null || true)
|
|
||||||
hostsfile=$(git -C "$repo_dir" config --get git-gate.knownHosts 2>/dev/null || true)
|
|
||||||
if [ -z "$keyfile" ] || [ ! -f "$hostsfile" ]; then
|
|
||||||
echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
|
|
||||||
|
|
||||||
echo "git-gate: refreshing $repo_dir from upstream" >&2
|
|
||||||
if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then
|
|
||||||
echo "git-gate: upstream fetch failed for $repo_dir; refusing to serve stale data" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Sync the bare repo's HEAD to upstream's HEAD on the first fetch
|
|
||||||
# (when it still points at the `git init --bare` default of
|
|
||||||
# refs/heads/master and upstream uses something else, the cloned
|
|
||||||
# checkout would fail with "remote HEAD refers to nonexistent ref").
|
|
||||||
# Costs one extra ls-remote on first fetch only; subsequent fetches
|
|
||||||
# skip the branch. If upstream's default branch changes after the
|
|
||||||
# gate has cached it, restart the bottle to resync.
|
|
||||||
if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
||||||
upstream_head=$(GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" \
|
|
||||||
ls-remote --symref origin HEAD 2>/dev/null \
|
|
||||||
| awk '/^ref:/ {print $2; exit}')
|
|
||||||
if [ -n "$upstream_head" ]; then
|
|
||||||
git -C "$repo_dir" symbolic-ref HEAD "$upstream_head" || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_dynamic_key(
|
|
||||||
entry: ManifestGitEntry,
|
|
||||||
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.Key
|
|
||||||
token = os.environ.get(pk.forge_token_env)
|
|
||||||
if token is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
|
||||||
f" = {pk.forge_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: ManifestBottle, 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.Key.provider != "gitea":
|
|
||||||
continue
|
|
||||||
pk = entry.Key
|
|
||||||
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.forge_token_env)
|
|
||||||
if token is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
|
||||||
f" = {pk.forge_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}]")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
|
|
||||||
"""Return the host-side SSH identity file path for this entry.
|
|
||||||
For gitea entries, provisions a fresh deploy key first."""
|
|
||||||
if entry.Key.provider == "gitea":
|
|
||||||
return _provision_dynamic_key(entry, slug, stage_dir)
|
|
||||||
return entry.IdentityFile
|
|
||||||
|
|
||||||
|
|
||||||
class GitGate(ABC):
|
|
||||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
|
||||||
(upstream lift + entrypoint/hook render); the sidecar's
|
|
||||||
start/stop lifecycle is backend-specific and lives on concrete
|
|
||||||
subclasses."""
|
|
||||||
|
|
||||||
def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
|
|
||||||
"""Compute the upstream table from `bottle.git` and write the
|
|
||||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
|
||||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
|
||||||
|
|
||||||
For `gitea` 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
|
|
||||||
`internal_network` / `egress_network` via `dataclasses.replace`
|
|
||||||
before passing the plan to `.start`."""
|
|
||||||
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
|
||||||
for i, entry in enumerate(bottle.git):
|
|
||||||
upstreams_list[i] = dataclasses.replace(
|
|
||||||
upstreams_list[i],
|
|
||||||
identity_file=_resolve_identity_file(entry, slug, stage_dir),
|
|
||||||
)
|
|
||||||
upstreams = tuple(upstreams_list)
|
|
||||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
|
||||||
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
|
||||||
entrypoint.chmod(0o600)
|
|
||||||
hook = stage_dir / "git_gate_pre_receive.sh"
|
|
||||||
hook.write_text(git_gate_render_hook())
|
|
||||||
hook.chmod(0o600)
|
|
||||||
access_hook = stage_dir / "git_gate_access_hook.sh"
|
|
||||||
access_hook.write_text(git_gate_render_access_hook())
|
|
||||||
# 0o700 (not 0o600): git daemon execs --access-hook directly,
|
|
||||||
# not via `sh`, so the script needs the x bit. docker cp
|
|
||||||
# preserves source mode into the container.
|
|
||||||
access_hook.chmod(0o700)
|
|
||||||
upstreams_with_files: list[GitGateUpstream] = []
|
|
||||||
for u in upstreams:
|
|
||||||
known_hosts_file = Path()
|
|
||||||
if u.known_host_key:
|
|
||||||
known_hosts_file = stage_dir / f"{u.name}-known_hosts"
|
|
||||||
known_hosts_file.write_text(
|
|
||||||
git_gate_known_hosts_line(
|
|
||||||
u.upstream_host, u.upstream_port, u.known_host_key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
known_hosts_file.chmod(0o600)
|
|
||||||
upstreams_with_files.append(
|
|
||||||
GitGateUpstream(
|
|
||||||
name=u.name,
|
|
||||||
upstream_url=u.upstream_url,
|
|
||||||
upstream_host=u.upstream_host,
|
|
||||||
upstream_port=u.upstream_port,
|
|
||||||
identity_file=u.identity_file,
|
|
||||||
known_host_key=u.known_host_key,
|
|
||||||
known_hosts_file=known_hosts_file,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return GitGatePlan(
|
|
||||||
slug=slug,
|
|
||||||
entrypoint_script=entrypoint,
|
|
||||||
hook_script=hook,
|
|
||||||
access_hook_script=access_hook,
|
|
||||||
upstreams=tuple(upstreams_with_files),
|
|
||||||
)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user