Compare commits
262 Commits
f7f161e60f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bfc6c5d16 | |||
| ed9adfb678 | |||
| b2f498cac5 | |||
| 46be9039b7 | |||
| caf1da580a | |||
| c44a1ffdc1 | |||
| 568cf4f35a | |||
| 8941b92e37 | |||
| cb5d22b25f | |||
| 049103ac99 | |||
| f71558aff6 | |||
| a80356af75 | |||
| 3d7c508dc4 | |||
| f42a0dc7fe | |||
| 480269b116 | |||
| 949b001464 | |||
| 656966c2c4 | |||
| 15f0e0b507 | |||
| dd2e83b8a9 | |||
| ce3fad9320 | |||
| c07ebca867 | |||
| c276f7b0b1 | |||
| 814c7338a1 | |||
| fa7c6ab9d8 | |||
| e27bd66080 | |||
| d496e30681 | |||
| 61740cdb6a | |||
| f9662c88a5 | |||
| e89dffa899 | |||
| 21d03b7cc9 | |||
| eb804f8674 | |||
| b765f87fb5 | |||
| 1084d8b79d | |||
| 36e62cd7e8 | |||
| 70e58f1333 | |||
| 3703b6f59f | |||
| afd943c4f9 | |||
| 8f31741c9f | |||
| 5bfe45bc1d | |||
| 7aca7d5289 | |||
| f6d4eb9e3c | |||
| c5b9f05467 | |||
| 09a2329f31 | |||
| 03c89dae81 | |||
| dd7232f368 | |||
| 76fdefded3 | |||
| 1085a2280d | |||
| c97f4ecb8d | |||
| 142974a4b8 | |||
| 041d9bbbf5 | |||
| 9fb83ef1b0 | |||
| e7e8c7fdb4 | |||
| ed8fbfdfa8 | |||
| 5fcca2060f | |||
| a4d82e5ff2 | |||
| e8e4f6f7c7 | |||
| 5f0fc0d540 | |||
| 244ad6a914 | |||
| 29904609da | |||
| 3067b067d2 | |||
| 212551df9a | |||
| f1b8bbdfa1 | |||
| 08918f9a8a | |||
| 9af02831ea | |||
| 5970b785aa | |||
| 2f5cf81cf5 | |||
| 4a1e667306 | |||
| b93fe58523 | |||
| 94eca35b4f | |||
| f787764364 | |||
| a256e5762a | |||
| b7f5f6439e | |||
| 09755c3e24 | |||
| 121dc84b9f | |||
| 2a67a85835 | |||
| 0bb47bd754 | |||
| ebbcae663c | |||
| fc6dd37dd9 | |||
| 33fe8d2c7a | |||
| 0db76b877a | |||
| 8006702ee7 | |||
| d62664106c | |||
| cb79a22930 | |||
| 0a3832f0fb | |||
| 005b745dfd | |||
| 2ad1b96e77 | |||
| 8caa79ee76 | |||
| 74060192e0 | |||
| 5365a7a852 | |||
| f289b6382c | |||
| 3073230f58 | |||
| 18059f2a78 | |||
| 632ab002ed | |||
| af7f74dc32 | |||
| eaf6b1f72e | |||
| ca910f8f4f | |||
| 338c08a243 | |||
| 6faa6f67aa | |||
| b6ae6af63a | |||
| ad72eeddc1 | |||
| 61f89de2da | |||
| 1ba185d1e0 | |||
| e82dbaba09 | |||
| d7fbe8e8a9 | |||
| 50f5b3aa7f | |||
| 45a096413f | |||
| c6479d62e4 | |||
| d0cad3a559 | |||
| c2ddac1be5 | |||
| 446414144e | |||
| 8188d6304e | |||
| 9f7c067e85 | |||
| 90e84a52e6 | |||
| 75755a472f | |||
| 2f3dc57fa9 | |||
| 302920e290 | |||
| ca1b4afaea | |||
| d2072b13be | |||
| 36c5b7025b | |||
| 515a95a79d | |||
| 0bace7615a | |||
| c0d3f16519 | |||
| 508c537deb | |||
| d99dba037c | |||
| 9a878bd885 | |||
| 0f72843150 | |||
| fd6b14fb32 | |||
| 9f9aa2e762 | |||
| 454baaf3a1 | |||
| 8a092504b8 | |||
| e7dacf7d86 | |||
| 9b929d0684 | |||
| ec41f629a4 | |||
| d9a9eef276 | |||
| 5204b98777 | |||
| 14ae89580a | |||
| 4808ef557a | |||
| 0a7e166b35 | |||
| a920203730 | |||
| e02fab15d0 | |||
| 11cf12188d | |||
| 701df6cb2f | |||
| ea6bc5a170 | |||
| ecaae708f7 | |||
| 2e790268b0 | |||
| a421d1d688 | |||
| d2d50be65a | |||
| 1ad710a041 | |||
| b411577e76 | |||
| cdfaaa3de8 | |||
| 7f2352287e | |||
| 7cb967770e | |||
| 80eca740d6 | |||
| 369d332204 | |||
| 31cde11b0d | |||
| c41751f3b9 | |||
| e2422c20a0 | |||
| de71533a17 | |||
| 88c4f61901 | |||
| c666eaa63f | |||
| 83eb9e4041 | |||
| 33333ac4d9 | |||
| 4d56f515bc | |||
| c48c3688b8 | |||
| 6040b20e6e | |||
| f2775101a0 | |||
| dd99c495f4 | |||
| eb64a52ffa | |||
| d11e3940fa | |||
| a32c0c7865 | |||
| ccb2956562 | |||
| c6362fda7b | |||
| cb321f7ad4 | |||
| 311cd46185 | |||
| 28335f453f | |||
| a1aa8feb85 | |||
| cb3bb209d6 | |||
| 6e73cc4d86 | |||
| 64fac71025 | |||
| f8ac22c316 | |||
| 9465857a99 | |||
| 200306f1cf | |||
| 77bdaf0a96 | |||
| 7e344bbb53 | |||
| 5eb27cd9a8 | |||
| 5808d0b828 | |||
| 7a991e1f5e | |||
| 5606797ac2 | |||
| ebbb4053cf | |||
| eb3e64ea8f | |||
| 0ec1085238 | |||
| 4c39b45e34 | |||
| 3ea35ba5d2 | |||
| 7c6ab62e26 | |||
| da42740156 | |||
| 56ef71060a | |||
| 294a6ed023 | |||
| 468ab8c290 | |||
| 2596c18954 | |||
| 3ccd09ed0d | |||
| 996a260a98 | |||
| 3375df3f52 | |||
| c9842ce831 | |||
| d314ccf455 | |||
| 31b29631b6 | |||
| 1c11110da5 | |||
| 25ca14a8a2 | |||
| b5b7f15ef9 | |||
| 85e64b5134 | |||
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 | |||
| 6e6890ebd9 | |||
| 609b3ed090 | |||
| 65faa40b9a | |||
| 9f97de115b | |||
| 8f21f4df19 | |||
| ff7a52c1d2 | |||
| 4ed6b84863 | |||
| 7a124d7d25 | |||
| f00c567469 | |||
| 6f0e5b4589 | |||
| 5da4d05bf2 | |||
| 1a8718ca9d | |||
| c1c225aa05 | |||
| dc7c10d6fe | |||
| a827b0841e | |||
| a9c93ea9df | |||
| bb69af31f8 | |||
| 7644da4280 | |||
| 13e4af421d | |||
| f2d5307573 | |||
| bc9a22b46a | |||
| 932e71c0bf | |||
| d3b0b330aa | |||
| 5e927bcd13 | |||
| 890a146413 | |||
| afdf0779a1 | |||
| eb7cae1fea | |||
| fe82dc7f2b | |||
| b00b0ba4aa | |||
| 3f04567290 | |||
| acb9cd67c6 | |||
| d90ab7e646 | |||
| 8ea90adcaf | |||
| de803e1e76 | |||
| 019efab804 | |||
| 957d37f51f | |||
| 8e084262a0 | |||
| 504144eb9c | |||
| 86374ab293 | |||
| 199edb228c | |||
| 598a20a3f0 | |||
| c8b5ba3812 | |||
| 5ea9fda69b | |||
| 4f7cfc0418 | |||
| 1f38a96561 | |||
| 660b9b3810 | |||
| 328069809b | |||
| b1551045dc | |||
| d02226aab9 | |||
| 39811c9b32 |
+18
@@ -0,0 +1,18 @@
|
|||||||
|
[run]
|
||||||
|
branch = True
|
||||||
|
source = .
|
||||||
|
|
||||||
|
[report]
|
||||||
|
# Coverage policy: see docs/decisions/0004-coverage-policy.md.
|
||||||
|
#
|
||||||
|
# `omit` is reserved for genuinely interactive entry-point shells whose
|
||||||
|
# bodies are `read_tty_line()` / curses prompt loops — there is no
|
||||||
|
# behaviour to assert that a test wouldn't have to fake wholesale, so a
|
||||||
|
# test here would inflate the number without buying confidence. This is
|
||||||
|
# NOT a place to hide subprocess/backend orchestration: that code is
|
||||||
|
# security-relevant and is measured via the integration suite instead
|
||||||
|
# (run scripts/coverage.sh for the combined unit+integration number).
|
||||||
|
omit =
|
||||||
|
bot_bottle/cli/tui.py
|
||||||
|
bot_bottle/cli/init.py
|
||||||
|
tests/*
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
# Run pylint on all Python files in the repo
|
# Run pylint on all Python files in the repo
|
||||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0 || true
|
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
||||||
|
|
||||||
- name: Run pyright
|
- name: Run pyright
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -39,8 +39,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dev requirements
|
||||||
|
run: python3 -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: python3 -m unittest discover -t . -s tests/unit -v
|
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||||
|
|
||||||
|
- name: Report unit coverage
|
||||||
|
run: python3 -m coverage report -m
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -64,3 +70,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||||
|
|
||||||
|
# Combined unit+integration coverage report (informational). See
|
||||||
|
# docs/decisions/0004-coverage-policy.md.
|
||||||
|
#
|
||||||
|
# The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
|
||||||
|
# Firecracker backend's VM/SSH orchestration is covered by the integration
|
||||||
|
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
|
||||||
|
# container-based runner skips it and those lines read uncovered, so the
|
||||||
|
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is
|
||||||
|
# tracked separately (see PRD 0069 / #348 and the ci-runner branch).
|
||||||
|
coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dev requirements
|
||||||
|
run: python3 -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Combined coverage report (unit + integration)
|
||||||
|
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- '.pylintrc'
|
- '.coveragerc'
|
||||||
- 'pyrightconfig.json'
|
# The core-coverage badge reads this list; refresh when it changes.
|
||||||
|
- 'scripts/critical-modules.txt'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -29,38 +30,39 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run pylint and extract score
|
- name: Run coverage and extract percentage
|
||||||
id: pylint
|
id: coverage
|
||||||
run: |
|
run: |
|
||||||
PYLINT_OUTPUT=$(python -m pylint bot_bottle/ 2>&1) || true
|
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||||
SCORE=$(echo "$PYLINT_OUTPUT" | grep -oP '(?<=rated at )\d+\.\d+/10' | head -1)
|
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Pylint score: $SCORE"
|
echo "Coverage: $PERCENT%"
|
||||||
|
|
||||||
- name: Run pyright and check errors
|
- name: Extract core (critical-module) coverage percentage
|
||||||
id: pyright
|
id: core_coverage
|
||||||
run: |
|
run: |
|
||||||
PYRIGHT_OUTPUT=$(python -m pyright 2>&1) || true
|
# Reuses the .coverage data from the previous step. The core list is
|
||||||
ERRORS=$(echo "$PYRIGHT_OUTPUT" | grep -oP '\d+(?= error)' | head -1)
|
# the single source of truth in scripts/critical-modules.txt; every
|
||||||
echo "errors=$ERRORS" >> $GITHUB_OUTPUT
|
# core module is unit-tested, so the unit-only run is accurate for it.
|
||||||
echo "Pyright errors: $ERRORS"
|
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||||
|
PERCENT=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
|
echo "Core coverage: $PERCENT%"
|
||||||
|
|
||||||
- name: Update badges in README
|
- name: Update badges in README
|
||||||
run: |
|
run: |
|
||||||
PYLINT_SCORE="${{ steps.pylint.outputs.score }}"
|
COVERAGE_PERCENT="${{ steps.coverage.outputs.percent }}"
|
||||||
PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}"
|
CORE_COVERAGE_PERCENT="${{ steps.core_coverage.outputs.percent }}"
|
||||||
|
|
||||||
PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g')
|
if [ -n "$COVERAGE_PERCENT" ]; then
|
||||||
|
sed -i "s|/badge/coverage-[^)]*|/badge/coverage-${COVERAGE_PERCENT}%25-brightgreen|" README.md
|
||||||
if [ -n "$PYLINT_SCORE_ENCODED" ]; then
|
|
||||||
sed -i "s|/badge/pylint-[^)]*|/badge/pylint-${PYLINT_SCORE_ENCODED}-brightgreen|" README.md
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$PYRIGHT_ERRORS" ]; then
|
if [ -n "$CORE_COVERAGE_PERCENT" ]; then
|
||||||
sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md
|
sed -i "s|/badge/core%20coverage-[^)]*|/badge/core%20coverage-${CORE_COVERAGE_PERCENT}%25-brightgreen|" README.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updated badges:"
|
echo "Updated badges:"
|
||||||
grep -E "pylint|pyright" README.md | head -2
|
grep -E "coverage" README.md | head -2
|
||||||
|
|
||||||
- name: Commit and push badge updates
|
- name: Commit and push badge updates
|
||||||
run: |
|
run: |
|
||||||
@@ -73,7 +75,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Badge changes detected, committing..."
|
echo "Badge changes detected, committing..."
|
||||||
git add README.md
|
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]"
|
MSG="chore: update quality badges"$'\n\n'"- Coverage: ${{ steps.coverage.outputs.percent }}%"$'\n'"- Core coverage: ${{ steps.core_coverage.outputs.percent }}%"$'\n\n'"[skip ci]"
|
||||||
git commit -m "$MSG"
|
git commit -m "$MSG"
|
||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ venv/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ 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
|
broad permissions inside a sandbox, so a misbehaving agent cannot reach the
|
||||||
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
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 runtime lifecycle and the copying of skills and env vars into it.
|
||||||
The default backend is smolmachines on macOS: agents run in a libkrun
|
The default backend on compatible macOS hosts is macos-container:
|
||||||
micro-VM, while the sidecar bundle still uses Docker. The legacy Docker
|
agents and sidecar bundles run through Apple's `container` CLI without
|
||||||
|
requiring Docker. On KVM-capable Linux hosts the default is firecracker:
|
||||||
|
agents run in a Firecracker microVM reached over SSH on a point-to-point
|
||||||
|
TAP, while the sidecar bundle still uses Docker. The legacy Docker
|
||||||
backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
||||||
`--backend=docker`.
|
`--backend=docker`.
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -18,13 +18,13 @@
|
|||||||
# /git-gate-entrypoint.sh 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-gate/creds/* docker-cp'd at start time
|
||||||
# /git/* bare repos, populated at runtime
|
# /git/* bare repos, populated at runtime
|
||||||
# /run/supervise/queue/ bind-mounted at run time
|
# /run/supervise/bot-bottle.db bind-mounted at run time
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
#
|
#
|
||||||
# Exposed ports inside the container:
|
# Exposed ports inside the container:
|
||||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
||||||
# 9418 git-gate (git-daemon)
|
# 9418 git-gate (git-daemon)
|
||||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
|
||||||
# 9100 supervise (MCP HTTP)
|
# 9100 supervise (MCP HTTP)
|
||||||
|
|
||||||
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
||||||
@@ -62,9 +62,16 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|||||||
# top-level siblings (absolute imports), matching the prior
|
# top-level siblings (absolute imports), matching the prior
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||||
|
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
|
COPY bot_bottle/migrations.py /app/migrations.py
|
||||||
|
COPY bot_bottle/db_store.py /app/db_store.py
|
||||||
|
COPY bot_bottle/supervise_types.py /app/supervise_types.py
|
||||||
|
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||||
|
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||||
|
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||||
@@ -80,7 +87,7 @@ RUN mkdir -p \
|
|||||||
/etc/git-gate \
|
/etc/git-gate \
|
||||||
/git-gate/creds \
|
/git-gate/creds \
|
||||||
/git \
|
/git \
|
||||||
/run/supervise/queue \
|
/run/supervise \
|
||||||
/home/mitmproxy/.mitmproxy
|
/home/mitmproxy/.mitmproxy
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
# Documentation only — the compose renderer publishes whichever
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
- **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.
|
||||||
|
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
@@ -23,12 +24,15 @@
|
|||||||
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
||||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||||
- **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.
|
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||||
- **Smolmachines backend (macOS default)** — 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.
|
- **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.
|
||||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without smolvm via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the sidecar bundle in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup.
|
||||||
|
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
On the default 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.
|
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.
|
||||||
|
|
||||||
|
On the Firecracker backend, a bottle is an agent microVM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the sidecars. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -67,9 +71,24 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
Requires Docker on the host for the sidecar bundle, smolvm on macOS for the default backend, and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the sidecar bundle plus the `firecracker` binary and KVM. 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`.
|
||||||
|
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where smolvm is not installed.
|
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||||
|
|
||||||
|
### Firecracker on Linux
|
||||||
|
|
||||||
|
On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
||||||
|
|
||||||
|
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||||
|
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
|
||||||
|
- **Docker** for the sidecar bundle and image build.
|
||||||
|
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
|
||||||
|
|
||||||
```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
|
||||||
@@ -103,8 +122,15 @@ egress:
|
|||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- host: gitea.dideric.is
|
||||||
auth:
|
auth:
|
||||||
scheme: token
|
scheme: token # Bearer | token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
|
matches: # optional — restrict to specific paths/methods/headers
|
||||||
|
- paths:
|
||||||
|
- {type: prefix, value: /api/v1/}
|
||||||
|
methods: [GET, POST, PATCH, DELETE]
|
||||||
|
dlp: # optional — per-route detector overrides (default: all on)
|
||||||
|
outbound_detectors: [token_patterns, known_secrets]
|
||||||
|
inbound_detectors: false # disable response scanning for this host
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
@@ -123,6 +149,26 @@ skills:
|
|||||||
You help maintain Gitea-hosted projects.
|
You help maintain Gitea-hosted projects.
|
||||||
````
|
````
|
||||||
|
|
||||||
|
**Egress route fields:**
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `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`). |
|
||||||
|
| `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. |
|
||||||
|
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||||
|
|
||||||
|
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `./cli.py supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
|
||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
PROVIDER_CLAUDE = "claude"
|
PROVIDER_CLAUDE = "claude"
|
||||||
PROVIDER_CODEX = "codex"
|
PROVIDER_CODEX = "codex"
|
||||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
PROVIDER_PI = "pi"
|
||||||
|
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
||||||
|
|
||||||
# Hosts that egress injects the host ChatGPT bearer on when Codex
|
# Hosts that egress injects the host ChatGPT bearer on when Codex
|
||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||||
PromptMode = Literal["append_file", "read_prompt_file"]
|
PromptMode = Literal[
|
||||||
|
"append_file",
|
||||||
|
"read_prompt_file",
|
||||||
|
"print_read_prompt_file",
|
||||||
|
"append_system_prompt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -55,7 +61,6 @@ class AgentProviderRuntime:
|
|||||||
prompt_mode: PromptMode
|
prompt_mode: PromptMode
|
||||||
bypass_args: tuple[str, ...]
|
bypass_args: tuple[str, ...]
|
||||||
resume_args: tuple[str, ...]
|
resume_args: tuple[str, ...]
|
||||||
remote_control_args: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -107,6 +112,8 @@ class AgentProvisionPlan:
|
|||||||
instance_name: str
|
instance_name: str
|
||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
guest_env: dict[str, str]
|
guest_env: dict[str, str]
|
||||||
|
has_prompt: bool = False
|
||||||
|
startup_args: tuple[str, ...] = ()
|
||||||
env_vars: dict[str, str] = field(default_factory=dict)
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
dirs: tuple[AgentProvisionDir, ...] = ()
|
dirs: tuple[AgentProvisionDir, ...] = ()
|
||||||
files: tuple[AgentProvisionFile, ...] = ()
|
files: tuple[AgentProvisionFile, ...] = ()
|
||||||
@@ -162,6 +169,7 @@ class AgentProvider(ABC):
|
|||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
label: str = "",
|
label: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
"""Build the declarative AgentProvisionPlan for one launch.
|
"""Build the declarative AgentProvisionPlan for one launch.
|
||||||
Backends call this during `prepare` and consume the result as
|
Backends call this during `prepare` and consume the result as
|
||||||
@@ -201,6 +209,15 @@ class AgentProvider(ABC):
|
|||||||
the supervise sidecar is reachable. No-op when
|
the supervise sidecar is reachable. No-op when
|
||||||
`plan.supervise_plan is None`."""
|
`plan.supervise_plan is None`."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def headless_prompt(self, prompt: str) -> list[str]:
|
||||||
|
"""Return the agent CLI args that deliver `prompt` as the
|
||||||
|
initial task in a non-interactive (headless) session.
|
||||||
|
|
||||||
|
Called only when ``--prompt`` is passed to
|
||||||
|
``./cli.py start --headless``; the returned args are appended
|
||||||
|
after the provider's ``bypass_args`` and ``startup_args``."""
|
||||||
|
|
||||||
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
||||||
"""Install the egress MITM CA into the agent's trust store.
|
"""Install the egress MITM CA into the agent's trust store.
|
||||||
|
|
||||||
@@ -210,6 +227,10 @@ class AgentProvider(ABC):
|
|||||||
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert
|
||||||
from .log import die
|
from .log import die
|
||||||
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
cert_host_path, label = select_ca_cert(plan.egress_plan)
|
||||||
|
# Ensure the target directory exists. A backend's rootfs build
|
||||||
|
# may not preserve the empty /usr/local/share/ca-certificates/
|
||||||
|
# directory; mkdir -p is idempotent and safe for all backends.
|
||||||
|
bottle.exec("mkdir -p /usr/local/share/ca-certificates", user="root")
|
||||||
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates",
|
||||||
@@ -231,7 +252,7 @@ class AgentProvider(ABC):
|
|||||||
BottleBackend.provision_workspace against the running bottle."""
|
BottleBackend.provision_workspace against the running bottle."""
|
||||||
from .log import info
|
from .log import info
|
||||||
|
|
||||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.manifest.bottle
|
||||||
if manifest_bottle.git:
|
if manifest_bottle.git:
|
||||||
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||||
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
||||||
@@ -318,6 +339,9 @@ def get_provider(template: str) -> AgentProvider:
|
|||||||
if template == PROVIDER_CODEX:
|
if template == PROVIDER_CODEX:
|
||||||
from .contrib.codex.agent_provider import CodexAgentProvider
|
from .contrib.codex.agent_provider import CodexAgentProvider
|
||||||
return 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}")
|
raise ValueError(f"unknown agent provider template: {template!r}")
|
||||||
|
|
||||||
|
|
||||||
@@ -339,6 +363,7 @@ def build_agent_provision_plan(
|
|||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
label: str = "",
|
label: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
"""Back-compat shim — `prepare` callers stay the same; the work
|
"""Back-compat shim — `prepare` callers stay the same; the work
|
||||||
now lives on the provider plugin."""
|
now lives on the provider plugin."""
|
||||||
@@ -354,9 +379,19 @@ def build_agent_provision_plan(
|
|||||||
trusted_project_path=trusted_project_path,
|
trusted_project_path=trusted_project_path,
|
||||||
label=label,
|
label=label,
|
||||||
color=color,
|
color=color,
|
||||||
|
provider_settings=provider_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_startup_args(
|
||||||
|
provider_settings: dict[str, object] | None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
raw = (provider_settings or {}).get("startup_args", ())
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
return ()
|
||||||
|
return tuple(arg for arg in raw if isinstance(arg, str))
|
||||||
|
|
||||||
|
|
||||||
def prompt_args(
|
def prompt_args(
|
||||||
prompt_mode: PromptMode,
|
prompt_mode: PromptMode,
|
||||||
prompt_path: str | None,
|
prompt_path: str | None,
|
||||||
@@ -368,7 +403,11 @@ def prompt_args(
|
|||||||
if prompt_mode == "append_file":
|
if prompt_mode == "append_file":
|
||||||
return ["--append-system-prompt-file", prompt_path]
|
return ["--append-system-prompt-file", prompt_path]
|
||||||
if prompt_mode == "read_prompt_file":
|
if prompt_mode == "read_prompt_file":
|
||||||
if argv and "resume" in argv:
|
if argv and ("resume" in argv or "remote-control" in argv):
|
||||||
return []
|
return []
|
||||||
return [f"Read and follow the instructions in {prompt_path}."]
|
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}")
|
raise ValueError(f"unknown provider prompt mode: {prompt_mode}")
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""SQLite-backed audit store for supervise (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .supervise_types import AuditEntry, host_db_path
|
||||||
|
from .db_store import DbStore
|
||||||
|
from .migrations import TableMigrations
|
||||||
|
except ImportError:
|
||||||
|
from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
|
class AuditStore(DbStore):
|
||||||
|
"""SQLite-backed persistent store for supervise audit entries."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path | None = None) -> None:
|
||||||
|
# One entry per schema version: migrations[0] brings a fresh DB to
|
||||||
|
# version 1, [1] to version 2, etc. Add new entries at the end; never
|
||||||
|
# edit existing ones.
|
||||||
|
migrations = TableMigrations("audit_store", [
|
||||||
|
# v1 — initial schema
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS supervise_audit_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
bottle_slug TEXT NOT NULL,
|
||||||
|
component TEXT NOT NULL,
|
||||||
|
operator_action TEXT NOT NULL,
|
||||||
|
operator_notes TEXT NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
])
|
||||||
|
super().__init__(db_path or host_db_path(), migrations)
|
||||||
|
|
||||||
|
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO supervise_audit_entries (
|
||||||
|
timestamp, bottle_slug, component, operator_action,
|
||||||
|
operator_notes, justification, diff
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
entry.timestamp,
|
||||||
|
entry.bottle_slug,
|
||||||
|
entry.component,
|
||||||
|
entry.operator_action,
|
||||||
|
entry.operator_notes,
|
||||||
|
entry.justification,
|
||||||
|
entry.diff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._chmod()
|
||||||
|
return self.db_path
|
||||||
|
|
||||||
|
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
||||||
|
if not self.db_path.is_file():
|
||||||
|
return []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM supervise_audit_entries
|
||||||
|
WHERE component = ? AND bottle_slug = ?
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(component, slug),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_entry(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_entry(row: sqlite3.Row) -> AuditEntry:
|
||||||
|
return AuditEntry(
|
||||||
|
timestamp=row["timestamp"],
|
||||||
|
bottle_slug=row["bottle_slug"],
|
||||||
|
component=row["component"],
|
||||||
|
operator_action=row["operator_action"],
|
||||||
|
operator_notes=row["operator_notes"],
|
||||||
|
justification=row["justification"],
|
||||||
|
diff=row["diff"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AuditStore"]
|
||||||
+130
-59
@@ -24,9 +24,11 @@ backend exposes five methods:
|
|||||||
enough metadata for callers (CLI `list active`, dashboard
|
enough metadata for callers (CLI `list active`, dashboard
|
||||||
agents pane) to render a row.
|
agents pane) to render a row.
|
||||||
|
|
||||||
Selection is driven by `--backend` on `start` or
|
Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND
|
||||||
BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the
|
(env var). When neither is set, compatible macOS hosts default to
|
||||||
manifest does not carry a backend field; the host picks.
|
`macos-container`; Linux hosts with KVM default to `firecracker`;
|
||||||
|
otherwise `docker`. Per PRD 0003 the manifest does not carry a
|
||||||
|
backend field; the host picks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -44,7 +46,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
|
|||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
from ..manifest import ManifestGitEntry, Manifest
|
from ..manifest import Manifest, ManifestIndex
|
||||||
from ..supervise import SupervisePlan
|
from ..supervise import SupervisePlan
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from ..env import resolve_env, ResolvedEnv
|
from ..env import resolve_env, ResolvedEnv
|
||||||
@@ -60,7 +62,7 @@ class BottleSpec:
|
|||||||
Resolved values (image names, container name, scratch paths, runsc
|
Resolved values (image names, container name, scratch paths, runsc
|
||||||
availability) live on the plan, not the spec."""
|
availability) live on the plan, not the spec."""
|
||||||
|
|
||||||
manifest: Manifest
|
manifest: ManifestIndex
|
||||||
agent_name: str
|
agent_name: str
|
||||||
copy_cwd: bool
|
copy_cwd: bool
|
||||||
user_cwd: str
|
user_cwd: str
|
||||||
@@ -71,6 +73,12 @@ class BottleSpec:
|
|||||||
identity: str = ""
|
identity: str = ""
|
||||||
label: str = ""
|
label: str = ""
|
||||||
color: str = ""
|
color: str = ""
|
||||||
|
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||||
|
# they are merged in order and replace the agent's `bottle:` field.
|
||||||
|
bottle_names: tuple[str, ...] = ()
|
||||||
|
# True when launched via --headless (no TTY, no interactive prompts).
|
||||||
|
# The git-gate host-key preflight uses this to error rather than prompt.
|
||||||
|
headless: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -79,6 +87,7 @@ class BottlePlan(ABC):
|
|||||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||||
|
|
||||||
spec: BottleSpec
|
spec: BottleSpec
|
||||||
|
manifest: Manifest
|
||||||
stage_dir: Path
|
stage_dir: Path
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
|
|
||||||
@@ -89,14 +98,14 @@ class BottlePlan(ABC):
|
|||||||
@property
|
@property
|
||||||
def git_gate_insteadof_host(self) -> str:
|
def git_gate_insteadof_host(self) -> str:
|
||||||
"""Host (and optional port) used in git-gate insteadOf URLs.
|
"""Host (and optional port) used in git-gate insteadOf URLs.
|
||||||
Docker uses the compose-network DNS alias; smolmachines
|
Docker uses the compose-network DNS alias; VM backends may
|
||||||
overrides with a loopback IP:port since TSI has no DNS."""
|
override with an IP:port when the guest has no DNS."""
|
||||||
return "git-gate"
|
return "git-gate"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def git_gate_insteadof_scheme(self) -> str:
|
def git_gate_insteadof_scheme(self) -> str:
|
||||||
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
||||||
Docker (git daemon); 'http' for smolmachines (HTTP proxy
|
Docker (git daemon); VM backends may override (e.g. 'http'
|
||||||
over a published host port)."""
|
over a published host port)."""
|
||||||
return "git"
|
return "git"
|
||||||
egress_plan: EgressPlan
|
egress_plan: EgressPlan
|
||||||
@@ -107,13 +116,12 @@ class BottlePlan(ABC):
|
|||||||
def workspace_plan(self) -> WorkspacePlan:
|
def workspace_plan(self) -> WorkspacePlan:
|
||||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
return workspace_plan(self.spec, guest_home=self.guest_home)
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
def print(self) -> None:
|
||||||
"""Render the y/N preflight summary to stderr."""
|
"""Render the y/N preflight summary to stderr."""
|
||||||
del remote_control
|
|
||||||
spec = self.spec
|
spec = self.spec
|
||||||
manifest = spec.manifest
|
manifest = self.manifest
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agent
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle
|
||||||
|
|
||||||
env_names = visible_agent_env_names(
|
env_names = visible_agent_env_names(
|
||||||
sorted(
|
sorted(
|
||||||
@@ -128,9 +136,13 @@ class BottlePlan(ABC):
|
|||||||
info(f"provider : {self.agent_provision.template}")
|
info(f"provider : {self.agent_provision.template}")
|
||||||
print_multi("env ", env_names)
|
print_multi("env ", env_names)
|
||||||
print_multi("skills ", list(agent.skills))
|
print_multi("skills ", list(agent.skills))
|
||||||
info(f"bottle : {agent.bottle}")
|
effective_bottles = (
|
||||||
|
list(spec.bottle_names) if spec.bottle_names
|
||||||
|
else ([agent.bottle] if agent.bottle else [])
|
||||||
|
)
|
||||||
|
print_multi("bottle ", effective_bottles)
|
||||||
|
|
||||||
identity = manifest.git_identity_summary(spec.agent_name)
|
identity = manifest.git_identity_summary()
|
||||||
if identity:
|
if identity:
|
||||||
info(f" git identity : {identity}")
|
info(f" git identity : {identity}")
|
||||||
|
|
||||||
@@ -171,8 +183,8 @@ class BottleCleanupPlan(ABC):
|
|||||||
class ExecResult:
|
class ExecResult:
|
||||||
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
|
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
|
||||||
impl populates it from a `subprocess.CompletedProcess`, but a
|
impl populates it from a `subprocess.CompletedProcess`, but a
|
||||||
future fly/smolmachines backend could populate it from any source
|
VM backend could populate it from any source that produces a
|
||||||
that produces a returncode + captured streams."""
|
returncode + captured streams."""
|
||||||
|
|
||||||
returncode: int
|
returncode: int
|
||||||
stdout: str
|
stdout: str
|
||||||
@@ -190,7 +202,7 @@ class ActiveAgent:
|
|||||||
of sidecar daemons currently up for this bottle (`egress`,
|
of sidecar daemons currently up for this bottle (`egress`,
|
||||||
`git-gate`, `supervise`); the dashboard uses it to
|
`git-gate`, `supervise`); the dashboard uses it to
|
||||||
gate edit verbs. `backend_name` is the matching key in
|
gate edit verbs. `backend_name` is the matching key in
|
||||||
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
|
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
|
||||||
list rendering to disambiguate and by the dashboard's
|
list rendering to disambiguate and by the dashboard's
|
||||||
re-attach path."""
|
re-attach path."""
|
||||||
|
|
||||||
@@ -288,15 +300,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
write_launch_metadata,
|
write_launch_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._validate(spec)
|
manifest = self._validate(spec)
|
||||||
|
|
||||||
self._preflight()
|
self._preflight()
|
||||||
|
|
||||||
manifest = spec.manifest
|
from ..git_gate_host_key import preflight_host_keys
|
||||||
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
manifest = preflight_host_keys(
|
||||||
|
manifest,
|
||||||
|
headless=spec.headless,
|
||||||
|
home_md=spec.manifest.home_md,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_bottle = manifest.bottle
|
||||||
manifest_agent_provider = manifest_bottle.agent_provider
|
manifest_agent_provider = manifest_bottle.agent_provider
|
||||||
agent_provider = get_provider(manifest_agent_provider.template)
|
agent_provider = get_provider(manifest_agent_provider.template)
|
||||||
resolved_env = resolve_env(manifest, spec.agent_name)
|
resolved_env = resolve_env(manifest)
|
||||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||||
|
|
||||||
slug = mint_slug(spec)
|
slug = mint_slug(spec)
|
||||||
@@ -312,7 +330,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
else:
|
else:
|
||||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||||
|
|
||||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||||
|
|
||||||
agent_provision_plan = build_agent_provision_plan(
|
agent_provision_plan = build_agent_provision_plan(
|
||||||
template=manifest_agent_provider.template,
|
template=manifest_agent_provider.template,
|
||||||
@@ -327,6 +345,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
trusted_project_path=workspace.workdir,
|
trusted_project_path=workspace.workdir,
|
||||||
label=spec.label,
|
label=spec.label,
|
||||||
color=spec.color,
|
color=spec.color,
|
||||||
|
provider_settings=manifest_agent_provider.settings,
|
||||||
)
|
)
|
||||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||||
@@ -335,6 +354,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
return self._resolve_plan(
|
return self._resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
@@ -353,18 +373,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> Manifest:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
"""Cross-backend pre-launch checks. Parses the selected agent and
|
||||||
the named skills are present on the host, and every git
|
its bottle (raising ManifestError on invalid content), confirms
|
||||||
IdentityFile resolves. Subclasses with additional preconditions
|
skills are present on the host, and every git IdentityFile resolves.
|
||||||
should override and call `super()._validate(spec)` first."""
|
|
||||||
manifest = spec.manifest
|
Returns the loaded Manifest for the selected agent. Subclasses with
|
||||||
manifest.require_agent(spec.agent_name)
|
additional preconditions should override and call
|
||||||
agent = manifest.agents[spec.agent_name]
|
`super()._validate(spec)` first."""
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(manifest.agent.skills)
|
||||||
self._validate_git_entries(bottle.git)
|
self._validate_agent_provider_dockerfile(spec, manifest)
|
||||||
self._validate_agent_provider_dockerfile(spec)
|
return manifest
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
"""Each named skill must be a directory under the host's
|
"""Each named skill must be a directory under the host's
|
||||||
@@ -378,18 +398,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
f"Create it under ~/.claude/skills/, then re-run."
|
f"Create it under ~/.claude/skills/, then re-run."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
|
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
||||||
"""Each entry's IdentityFile must exist on the host (after
|
bottle = manifest.bottle
|
||||||
expanding leading ~) — the git-gate copies it in at start time
|
|
||||||
to authenticate the upstream push (PRD 0008). Shape is already
|
|
||||||
enforced by Manifest validation; this only checks presence."""
|
|
||||||
for entry in entries:
|
|
||||||
key = expand_tilde(entry.IdentityFile)
|
|
||||||
if not os.path.isfile(key):
|
|
||||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
|
||||||
|
|
||||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
|
||||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
|
||||||
dockerfile = bottle.agent_provider.dockerfile
|
dockerfile = bottle.agent_provider.dockerfile
|
||||||
if not dockerfile:
|
if not dockerfile:
|
||||||
return
|
return
|
||||||
@@ -397,15 +407,19 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
path = Path(spec.user_cwd) / path
|
path = Path(spec.user_cwd) / path
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
|
effective = (
|
||||||
|
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
|
||||||
|
)
|
||||||
die(
|
die(
|
||||||
f"agent_provider.dockerfile for bottle "
|
f"agent_provider.dockerfile for bottle "
|
||||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
f"'{effective}' not found: {path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _resolve_plan(self,
|
def _resolve_plan(self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -493,7 +507,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
MCP entry inside the guest.
|
MCP entry inside the guest.
|
||||||
|
|
||||||
Default returns "" so backends without supervise support
|
Default returns "" so backends without supervise support
|
||||||
don't have to implement it. Docker and smolmachines override."""
|
don't have to implement it. Docker and firecracker override."""
|
||||||
del plan
|
del plan
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -510,26 +524,65 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
"""Return every currently-running agent on this backend.
|
"""Return every currently-running agent on this backend.
|
||||||
Empty when none. Backend-specific: docker queries `docker
|
Empty when none. Backend-specific: docker queries `docker
|
||||||
compose ls`; smolmachines queries `smolvm machine ls --json`
|
compose ls`; firecracker cross-references its running sidecar
|
||||||
+ cross-references its bundle container."""
|
containers against per-bottle metadata."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def is_available(cls) -> bool:
|
def is_available(cls) -> bool:
|
||||||
"""Whether this backend's runtime prerequisites are satisfied
|
"""Whether this backend's runtime prerequisites are satisfied
|
||||||
on the current host. Docker → `docker` on PATH; smolmachines
|
on the current host. Docker → `docker` on PATH; firecracker →
|
||||||
→ `smolvm` on PATH. Used by the cross-backend
|
Linux + KVM. Used by the cross-backend
|
||||||
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
||||||
the operator hasn't installed, so a docker-only host
|
the operator hasn't installed, so a docker-only host
|
||||||
doesn't fail when `cli.py list active` walks past
|
doesn't fail when `cli.py list active` walks past
|
||||||
smolmachines."""
|
firecracker."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def setup(cls) -> int:
|
||||||
|
"""Emit this backend's one-time host setup — privileged network
|
||||||
|
pool, daemon bring-up, install pointers, etc. — as
|
||||||
|
host-appropriate config or commands. Prints to stdout/stderr and
|
||||||
|
returns a shell exit code (0 = nothing to report / success). A
|
||||||
|
backend that needs no host setup prints a short note and returns
|
||||||
|
0. Invoked generically by `./cli.py backend setup [--backend=…]`
|
||||||
|
so operators can provision any backend without a
|
||||||
|
backend-specific command. Classmethod (like `is_available`) —
|
||||||
|
it's a host query, not per-bottle state."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def status(cls) -> int:
|
||||||
|
"""Report whether this backend's prerequisites are satisfied on
|
||||||
|
the host — binaries, daemon reachability, network pool, range
|
||||||
|
conflicts, etc. Prints a human-readable summary; returns 0 when
|
||||||
|
the backend is ready to launch and non-zero when something is
|
||||||
|
missing. Invoked by `./cli.py backend status [--backend=…]`."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
"""Undo `setup()` — the inverse operation, surfaced as
|
||||||
|
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
|
||||||
|
with setup: where setup is advisory (prints the privileged
|
||||||
|
commands / declarative config to apply), teardown prints the
|
||||||
|
commands / config change to remove the host prerequisites. A
|
||||||
|
backend with no host setup prints a short note and returns 0.
|
||||||
|
Not called by the launch path or the test suite."""
|
||||||
|
|
||||||
|
|
||||||
# Import concrete backend classes AFTER the base types are defined, so
|
# Import concrete backend classes AFTER the base types are defined, so
|
||||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||||
# via `from . import ...` without hitting a partially-initialized module.
|
# via `from . import ...` without hitting a partially-initialized module.
|
||||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
from .macos_container import MacosContainerBottleBackend # 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
|
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||||
@@ -538,7 +591,8 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: dis
|
|||||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||||
"docker": DockerBottleBackend(),
|
"docker": DockerBottleBackend(),
|
||||||
"smolmachines": SmolmachinesBottleBackend(),
|
"firecracker": FirecrackerBottleBackend(),
|
||||||
|
"macos-container": MacosContainerBottleBackend(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -550,17 +604,31 @@ def get_bottle_backend(
|
|||||||
`name` precedence:
|
`name` precedence:
|
||||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||||
2. BOT_BOTTLE_BACKEND env var
|
2. BOT_BOTTLE_BACKEND env var
|
||||||
3. default `smolmachines`
|
3. `macos-container` on compatible macOS hosts
|
||||||
|
4. `firecracker` on KVM-capable Linux hosts
|
||||||
|
5. default `docker`
|
||||||
|
|
||||||
Dies with a pointer at the known backends if the chosen name
|
Dies with a pointer at the known backends if the chosen name
|
||||||
isn't implemented."""
|
isn't implemented."""
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines"
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
||||||
if resolved not in _BACKENDS:
|
if resolved not in _BACKENDS:
|
||||||
known = ", ".join(sorted(_BACKENDS))
|
known = ", ".join(sorted(_BACKENDS))
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||||
return _BACKENDS[resolved]
|
return _BACKENDS[resolved]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_backend_name() -> str:
|
||||||
|
if has_backend("macos-container"):
|
||||||
|
return "macos-container"
|
||||||
|
# A KVM-capable Linux host defaults to firecracker even when the
|
||||||
|
# `firecracker` binary isn't installed yet: selecting it here routes
|
||||||
|
# start through firecracker's preflight, which prints an install
|
||||||
|
# pointer, instead of silently falling back to docker.
|
||||||
|
if FirecrackerBottleBackend.is_host_capable():
|
||||||
|
return "firecracker"
|
||||||
|
return "docker"
|
||||||
|
|
||||||
|
|
||||||
def known_backend_names() -> tuple[str, ...]:
|
def known_backend_names() -> tuple[str, ...]:
|
||||||
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
argparse (`--backend` choices) and the dashboard's backend
|
||||||
@@ -572,7 +640,7 @@ def has_backend(name: str) -> bool:
|
|||||||
"""Whether the named backend's runtime prerequisites are
|
"""Whether the named backend's runtime prerequisites are
|
||||||
available on the current host. Cross-backend callers (list,
|
available on the current host. Cross-backend callers (list,
|
||||||
cleanup) skip unavailable backends so a docker-only host
|
cleanup) skip unavailable backends so a docker-only host
|
||||||
doesn't fail when the smolmachines backend isn't installed,
|
doesn't fail when the firecracker backend isn't usable,
|
||||||
and vice versa.
|
and vice versa.
|
||||||
|
|
||||||
Returns False for unknown names so callers can pass
|
Returns False for unknown names so callers can pass
|
||||||
@@ -610,9 +678,12 @@ __all__ = [
|
|||||||
"BottleCleanupPlan",
|
"BottleCleanupPlan",
|
||||||
"BottlePlan",
|
"BottlePlan",
|
||||||
"BottleSpec",
|
"BottleSpec",
|
||||||
|
"CommitCancelled",
|
||||||
"ExecResult",
|
"ExecResult",
|
||||||
|
"Freezer",
|
||||||
"enumerate_active_agents",
|
"enumerate_active_agents",
|
||||||
"get_bottle_backend",
|
"get_bottle_backend",
|
||||||
|
"get_freezer",
|
||||||
"has_backend",
|
"has_backend",
|
||||||
"known_backend_names",
|
"known_backend_names",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
@@ -53,6 +54,21 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
launch."""
|
launch."""
|
||||||
return shutil.which("docker") is not None
|
return shutil.which("docker") is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.setup()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def status(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
@@ -63,6 +79,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
self,
|
self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -73,6 +90,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import cast
|
|||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
from ..terminal import exec_shell_script
|
||||||
|
|
||||||
|
|
||||||
class DockerBottle(Bottle):
|
class DockerBottle(Bottle):
|
||||||
@@ -22,15 +23,20 @@ class DockerBottle(Bottle):
|
|||||||
*,
|
*,
|
||||||
agent_command: str = "claude",
|
agent_command: str = "claude",
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
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.name = container
|
||||||
self._teardown = teardown
|
self._teardown = teardown
|
||||||
self.prompt_path = prompt_path_in_container
|
self.prompt_path = prompt_path_in_container
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.agent_provider_template = (
|
self.terminal_title = terminal_title
|
||||||
"codex" if agent_command == "codex" else "claude"
|
self.terminal_color = terminal_color
|
||||||
)
|
self.agent_provider_template = agent_provider_template
|
||||||
|
self.agent_workdir = agent_workdir
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def agent_argv(
|
def agent_argv(
|
||||||
@@ -43,13 +49,17 @@ class DockerBottle(Bottle):
|
|||||||
cmd = ["docker", "exec"]
|
cmd = ["docker", "exec"]
|
||||||
if tty:
|
if tty:
|
||||||
cmd.append("-it")
|
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])
|
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
return subprocess.run(
|
agent_argv = self.agent_argv(argv, tty=tty)
|
||||||
self.agent_argv(argv, tty=tty), check=False,
|
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||||
).returncode
|
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:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
# Pipe via stdin to `sh -s` so the caller never has to worry
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -18,8 +18,7 @@ scan, just as a fallback bucket alongside the project list.
|
|||||||
|
|
||||||
`cleanup` removes everything in the plan.
|
`cleanup` removes everything in the plan.
|
||||||
|
|
||||||
Active-agent enumeration lives in `backend/docker/enumerate.py`
|
Active-agent enumeration lives in `backend/docker/enumerate.py`.
|
||||||
(mirror of `backend/smolmachines/enumerate.py`).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -93,8 +92,8 @@ def _list_orphan_state_dirs(
|
|||||||
|
|
||||||
`protected_identities` is the set of slugs that are live in
|
`protected_identities` is the set of slugs that are live in
|
||||||
ANY backend — used so this docker-side check doesn't reap a
|
ANY backend — used so this docker-side check doesn't reap a
|
||||||
running smolmachines bottle's state dir (the layout is shared
|
running non-docker bottle's state dir (the layout is shared
|
||||||
across both backends)."""
|
across backends)."""
|
||||||
state_root = _supervise.bot_bottle_root() / "state"
|
state_root = _supervise.bot_bottle_root() / "state"
|
||||||
if not state_root.is_dir():
|
if not state_root.is_dir():
|
||||||
return []
|
return []
|
||||||
@@ -119,7 +118,7 @@ def prepare_cleanup() -> DockerBottleCleanupPlan:
|
|||||||
|
|
||||||
Pulls the union of live identities across backends via
|
Pulls the union of live identities across backends via
|
||||||
`enumerate_active_agents()` so the orphan-state-dir bucket
|
`enumerate_active_agents()` so the orphan-state-dir bucket
|
||||||
doesn't include slugs whose smolmachines VM is still up."""
|
doesn't include slugs whose non-docker bottle is still up."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
projects = list_compose_projects()
|
projects = list_compose_projects()
|
||||||
project_set = set(projects)
|
project_set = set(projects)
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ from typing import Any
|
|||||||
from ...egress import (
|
from ...egress import (
|
||||||
EGRESS_HOSTNAME,
|
EGRESS_HOSTNAME,
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
EGRESS_ROUTES_IN_CONTAINER,
|
||||||
|
egress_agent_env_entries,
|
||||||
|
egress_sidecar_env_entries,
|
||||||
)
|
)
|
||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
from ...git_gate import GIT_GATE_HOSTNAME
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ...supervise import (
|
from ...supervise import (
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
DB_PATH_IN_CONTAINER,
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
|
||||||
SUPERVISE_HOSTNAME,
|
SUPERVISE_HOSTNAME,
|
||||||
SUPERVISE_PORT,
|
SUPERVISE_PORT,
|
||||||
)
|
)
|
||||||
@@ -134,9 +135,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
ep = plan.egress_plan
|
ep = plan.egress_plan
|
||||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||||
if ep.routes:
|
if ep.routes:
|
||||||
volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
|
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.extend(egress_sidecar_env_entries(ep))
|
||||||
env.append(token_env)
|
|
||||||
|
|
||||||
# --- git-gate -----------------------------------------------------
|
# --- git-gate -----------------------------------------------------
|
||||||
gp = plan.git_gate_plan
|
gp = plan.git_gate_plan
|
||||||
@@ -163,16 +163,15 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if sp is not None:
|
if sp is not None:
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
volumes.append({
|
volumes.append({
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
"source": str(sp.queue_dir),
|
"source": str(sp.db_path),
|
||||||
"target": QUEUE_DIR_IN_CONTAINER,
|
"target": DB_PATH_IN_CONTAINER,
|
||||||
"read_only": False,
|
"read_only": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
internal_aliases = [EGRESS_HOSTNAME]
|
internal_aliases = [EGRESS_HOSTNAME]
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||||
@@ -220,6 +219,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
# never lands on argv or in the compose file.
|
# never lands on argv or in the compose file.
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
for name in sorted(plan.forwarded_env.keys()):
|
||||||
env.append(name)
|
env.append(name)
|
||||||
|
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
service: dict[str, Any] = {
|
||||||
"image": plan.image,
|
"image": plan.image,
|
||||||
@@ -231,15 +231,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if plan.use_runsc:
|
if plan.use_runsc:
|
||||||
service["runtime"] = "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
|
# The init supervisor inside the bundle owns intra-bundle
|
||||||
# daemon ordering, so the agent only waits for the bundle
|
# daemon ordering, so the agent only waits for the bundle
|
||||||
# container itself.
|
# container itself.
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
"""Host-side helper for egress sidecar inspection (issue #198).
|
"""Host-side helper for egress sidecar inspection and live updates.
|
||||||
|
|
||||||
`_merge_single_route`, `add_route`, and `apply_routes_change` were
|
The approve path uses this module to validate a proposed routes file,
|
||||||
removed when the egress-block MCP tool was dropped. The remaining
|
write it to the bottle's live egress state dir, and signal the sidecar
|
||||||
helpers support runtime inspection and validation of the routes file
|
bundle so the mitmproxy addon reloads it.
|
||||||
without modifying it at runtime.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||||
from ...egress_addon_core import load_routes
|
from ...log import warn
|
||||||
|
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
|
|
||||||
|
|
||||||
class EgressApplyError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
def fetch_current_routes(slug: str) -> str:
|
||||||
container = sidecar_bundle_container_name(slug)
|
container = sidecar_bundle_container_name(slug)
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
@@ -33,17 +30,31 @@ def fetch_current_routes(slug: str) -> str:
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
def validate_routes_content(content: str) -> None:
|
class DockerEgressApplicator(EgressApplicator):
|
||||||
try:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
load_routes(content)
|
container = sidecar_bundle_container_name(slug)
|
||||||
except ValueError as e:
|
result = subprocess.run(
|
||||||
raise EgressApplyError(
|
["docker", "kill", "--signal", "HUP", container],
|
||||||
f"proposed routes.yaml is not valid: {e}"
|
capture_output=True, text=True, check=False, env=os.environ,
|
||||||
) from e
|
)
|
||||||
|
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__ = [
|
__all__ = [
|
||||||
|
"DockerEgressApplicator",
|
||||||
"EgressApplyError",
|
"EgressApplyError",
|
||||||
|
"applicator",
|
||||||
"fetch_current_routes",
|
"fetch_current_routes",
|
||||||
"validate_routes_content",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Active-agent enumeration for the docker backend.
|
"""Active-agent enumeration for the docker backend.
|
||||||
|
|
||||||
Mirrors `backend/smolmachines/enumerate.py`: returns
|
Returns `ActiveAgent` records the CLI `list active` command and the
|
||||||
`ActiveAgent` records the CLI `list active` command and the
|
|
||||||
dashboard agents pane consume. Empty when docker isn't reachable
|
dashboard agents pane consume. Empty when docker isn't reachable
|
||||||
— gated by `has_backend('docker')` at the cross-backend caller
|
— gated by `has_backend('docker')` at the cross-backend caller
|
||||||
so this module trusts that docker is available when called.
|
so this module trusts that docker is available when called.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""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")
|
||||||
@@ -37,7 +37,10 @@ from pathlib import Path
|
|||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...egress import egress_resolve_token_values
|
from ...egress import egress_resolve_token_values
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import (
|
||||||
|
provision_git_gate_dynamic_keys,
|
||||||
|
revoke_git_gate_provisioned_keys,
|
||||||
|
)
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -47,6 +50,7 @@ from ...bottle_state import (
|
|||||||
bottle_state_dir,
|
bottle_state_dir,
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
|
read_committed_image,
|
||||||
)
|
)
|
||||||
from .compose import (
|
from .compose import (
|
||||||
bottle_plan_to_compose,
|
bottle_plan_to_compose,
|
||||||
@@ -75,7 +79,7 @@ def launch(
|
|||||||
Teardown on exit."""
|
Teardown on exit."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
|
|
||||||
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
_bottle_for_revoke = plan.manifest.bottle
|
||||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
@@ -91,12 +95,22 @@ def launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: agent image build. Sidecar images get built lazily by
|
# Step 1: agent image. Use a committed snapshot when one exists
|
||||||
# `docker compose up` via the renderer's `build:` directives.
|
# and is present in the local daemon; otherwise build from the
|
||||||
docker_mod.build_image(
|
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||||
plan.image, _REPO_DIR,
|
# up` via the renderer's `build:` directives.
|
||||||
dockerfile=plan.dockerfile_path,
|
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)
|
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||||
@@ -107,6 +121,11 @@ def launch(
|
|||||||
|
|
||||||
git_gate_plan = plan.git_gate_plan
|
git_gate_plan = plan.git_gate_plan
|
||||||
if git_gate_plan.upstreams:
|
if git_gate_plan.upstreams:
|
||||||
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
|
plan.manifest.bottle,
|
||||||
|
git_gate_plan,
|
||||||
|
git_gate_state_dir(plan.slug),
|
||||||
|
)
|
||||||
git_gate_plan = dataclasses.replace(
|
git_gate_plan = dataclasses.replace(
|
||||||
git_gate_plan,
|
git_gate_plan,
|
||||||
internal_network=internal_network,
|
internal_network=internal_network,
|
||||||
@@ -175,6 +194,10 @@ def launch(
|
|||||||
None,
|
None,
|
||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
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)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .. import BottleSpec
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...agent_provider import AgentProvisionPlan
|
from ...agent_provider import AgentProvisionPlan
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -48,6 +50,7 @@ def resolve_plan(
|
|||||||
|
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
forwarded_env=dict(resolved_env.forwarded),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Host setup + status for the Docker backend.
|
||||||
|
|
||||||
|
Unlike Firecracker, the Docker backend needs no privileged one-time
|
||||||
|
host provisioning (no TAP pool / nft table) — networks and the sidecar
|
||||||
|
bundle are created per-launch. So `setup()` is mostly an install/daemon
|
||||||
|
pointer, and `status()` reports whether docker is usable.
|
||||||
|
|
||||||
|
This is intentionally minimal; a richer version (daemon config checks,
|
||||||
|
gVisor/runsc install guidance, rootless-docker hints) is tracked
|
||||||
|
separately. Reached via `DockerBottleBackend.setup` / `.status`, which
|
||||||
|
the generic `./cli.py backend {setup,status}` dispatches to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import util as _util
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_on_path() -> bool:
|
||||||
|
return shutil.which("docker") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _daemon_reachable() -> bool:
|
||||||
|
if not _docker_on_path():
|
||||||
|
return False
|
||||||
|
return subprocess.run(
|
||||||
|
["docker", "info"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _print_install_pointer() -> None:
|
||||||
|
sys.stderr.write("Docker is required but was not found on PATH.\n")
|
||||||
|
sys.stderr.write(" macOS: Docker Desktop https://docs.docker.com/desktop/install/mac-install/\n")
|
||||||
|
sys.stderr.write(" Linux: Docker Engine https://docs.docker.com/engine/install/\n")
|
||||||
|
|
||||||
|
|
||||||
|
def setup() -> int:
|
||||||
|
if not _docker_on_path():
|
||||||
|
_print_install_pointer()
|
||||||
|
return 1
|
||||||
|
sys.stderr.write(
|
||||||
|
"Docker backend: no privileged host setup required — networks and "
|
||||||
|
"the sidecar bundle are created per-launch.\n"
|
||||||
|
)
|
||||||
|
if not _daemon_reachable():
|
||||||
|
sys.stderr.write(
|
||||||
|
"The Docker daemon isn't reachable; start it (e.g. Docker "
|
||||||
|
"Desktop, or `systemctl start docker`).\n"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if not _util.runsc_available():
|
||||||
|
sys.stderr.write(
|
||||||
|
"Optional: register the gVisor (`runsc`) runtime with Docker for "
|
||||||
|
"a userspace syscall barrier; bottles auto-detect and use it.\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def teardown() -> int:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Docker backend: nothing to undo — it provisions no privileged host "
|
||||||
|
"state (networks and the sidecar bundle are per-launch and are "
|
||||||
|
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> int:
|
||||||
|
ok = True
|
||||||
|
if _docker_on_path():
|
||||||
|
sys.stderr.write("docker on PATH: yes\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write("docker on PATH: NO\n")
|
||||||
|
ok = False
|
||||||
|
if ok and _daemon_reachable():
|
||||||
|
sys.stderr.write("docker daemon: reachable\n")
|
||||||
|
elif ok:
|
||||||
|
sys.stderr.write("docker daemon: UNREACHABLE\n")
|
||||||
|
ok = False
|
||||||
|
runsc = _docker_on_path() and _util.runsc_available()
|
||||||
|
sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n")
|
||||||
|
if not ok:
|
||||||
|
sys.stderr.write("\nRun: ./cli.py backend setup --backend=docker\n")
|
||||||
|
return 0 if ok else 1
|
||||||
@@ -152,34 +152,19 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
# )
|
# )
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
def commit_container(container_name: str, image_tag: str) -> None:
|
||||||
"""Return the content-addressed image ID (e.g.
|
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||||
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
running container's filesystem state as a local Docker image."""
|
||||||
`.smolmachine` artifact cache on this, so a Dockerfile change
|
result = subprocess.run(
|
||||||
that produces a new image automatically invalidates the cache."""
|
["docker", "commit", container_name, image_tag],
|
||||||
r = subprocess.run(
|
capture_output=True, text=True, check=False,
|
||||||
["docker", "image", "inspect", "--format", "{{.Id}}", ref],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if result.returncode != 0:
|
||||||
die(
|
die(
|
||||||
f"docker image inspect for {ref!r} failed: "
|
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
)
|
)
|
||||||
return r.stdout.strip()
|
info(f"committed {container_name!r} → {image_tag!r}")
|
||||||
|
|
||||||
|
|
||||||
def save(ref: str, output: str) -> None:
|
|
||||||
"""`docker save REF -o OUTPUT`. Writes a tarball of the image
|
|
||||||
layers + manifest to the host path. Used by smolmachines
|
|
||||||
prepare to hand the agent image to a containerized crane that
|
|
||||||
pushes it to the ephemeral registry — bypassing the docker
|
|
||||||
daemon's `docker push` (which on Docker Desktop can't reach a
|
|
||||||
host-loopback registry and refuses plain-HTTP pushes to
|
|
||||||
non-loopback hosts)."""
|
|
||||||
subprocess.run(["docker", "save", ref, "-o", output], check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _silent_run(cmd: Iterable[str]) -> int:
|
def _silent_run(cmd: Iterable[str]) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""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 LOG_OFF, load_config
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
config = load_config(content)
|
||||||
|
except ValueError as e:
|
||||||
|
raise EgressApplyError(
|
||||||
|
f"proposed routes.yaml is not valid: {e}"
|
||||||
|
) from e
|
||||||
|
if config.log != LOG_OFF:
|
||||||
|
raise EgressApplyError(
|
||||||
|
"proposed routes.yaml must not change egress logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Firecracker backend: Linux KVM microVM isolation (issue #342)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .backend import FirecrackerBottleBackend
|
||||||
|
|
||||||
|
__all__ = ["FirecrackerBottleBackend"]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""FirecrackerBottleBackend — Linux microVM implementation.
|
||||||
|
|
||||||
|
Replaces smolmachines on Linux (issue #342): mature KVM-based
|
||||||
|
isolation via Firecracker, SSH control over a point-to-point TAP, and a
|
||||||
|
fail-closed nftables egress boundary. Selected by
|
||||||
|
`BOT_BOTTLE_BACKEND=firecracker` or `--backend=firecracker`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 ...manifest import Manifest
|
||||||
|
from ...supervise import SupervisePlan
|
||||||
|
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 _util
|
||||||
|
from .bottle import FirecrackerBottle
|
||||||
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
class FirecrackerBottleBackend(
|
||||||
|
BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan"]
|
||||||
|
):
|
||||||
|
name = "firecracker"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
return _util.is_available()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_host_capable(cls) -> bool:
|
||||||
|
"""Linux + KVM, regardless of whether the `firecracker` binary
|
||||||
|
is installed. Drives default-backend selection so a capable host
|
||||||
|
without the binary still lands on firecracker and gets an
|
||||||
|
install pointer at launch."""
|
||||||
|
return _util.is_host_capable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.setup()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def status(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> FirecrackerBottlePlan:
|
||||||
|
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: FirecrackerBottlePlan
|
||||||
|
) -> Generator[FirecrackerBottle, None, None]:
|
||||||
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
|
yield bottle
|
||||||
|
|
||||||
|
def prepare_cleanup(self) -> FirecrackerBottleCleanupPlan:
|
||||||
|
return _cleanup.prepare_cleanup()
|
||||||
|
|
||||||
|
def cleanup(self, plan: FirecrackerBottleCleanupPlan) -> None:
|
||||||
|
_cleanup.cleanup(plan)
|
||||||
|
|
||||||
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
|
return _enumerate.enumerate_active()
|
||||||
|
|
||||||
|
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
|
||||||
|
return plan.agent_supervise_url
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Bottle handle for a Firecracker microVM, over SSH.
|
||||||
|
|
||||||
|
Every operation routes through SSH to the guest (dropbear, listening on
|
||||||
|
the TAP link). `exec` pipes a script over stdin and captures output;
|
||||||
|
`cp_in` uses scp; `exec_agent` uses `ssh -t` for an interactive PTY
|
||||||
|
session. `ssh -t` forwards the host terminal's SIGWINCH to the remote
|
||||||
|
PTY natively, so no separate resize bridge is needed.
|
||||||
|
|
||||||
|
Commands run as the image's `node` user via `runuser`, with HOME/USER/
|
||||||
|
PATH and the bottle env (HTTPS_PROXY at the sidecar, CA paths, …) set
|
||||||
|
per-invocation through `env` (the VM itself just runs the init; it has
|
||||||
|
no baked-in process env like a `docker run` container would).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, cast
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
|
from .. import Bottle, ExecResult
|
||||||
|
from ..terminal import exec_shell_script
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
_HOME_FOR = {"node": "/home/node", "root": "/root"}
|
||||||
|
_DEFAULT_PATH_FOR = {
|
||||||
|
"node": (
|
||||||
|
"/home/node/.local/bin:"
|
||||||
|
"/home/node/.codex/packages/standalone/current/bin:"
|
||||||
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
),
|
||||||
|
"root": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}"]
|
||||||
|
if "PATH" not in env:
|
||||||
|
out.append(f"PATH={_DEFAULT_PATH_FOR.get(user, _DEFAULT_PATH_FOR['root'])}")
|
||||||
|
for k, v in env.items():
|
||||||
|
out.append(f"{k}={v}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class FirecrackerBottle(Bottle):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
private_key: Path,
|
||||||
|
guest_ip: str,
|
||||||
|
guest_env: Mapping[str, str] | None = None,
|
||||||
|
prompt_path_in_guest: 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 = name
|
||||||
|
self._private_key = private_key
|
||||||
|
self._guest_ip = guest_ip
|
||||||
|
self._guest_env = dict(guest_env or {})
|
||||||
|
self.prompt_path = prompt_path_in_guest
|
||||||
|
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 _ssh(self, *, tty: bool) -> list[str]:
|
||||||
|
argv = util.ssh_base_argv(self._private_key, self._guest_ip)
|
||||||
|
if tty:
|
||||||
|
# -t: allocate a remote PTY and forward window-size changes.
|
||||||
|
argv.insert(1, "-t")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
def _agent_remote_argv(self, argv: list[str]) -> list[str]:
|
||||||
|
"""The `runuser … <agent> …` command run inside the guest."""
|
||||||
|
full_argv = list(argv)
|
||||||
|
full_argv.extend(
|
||||||
|
prompt_args(
|
||||||
|
cast(PromptMode, self._agent_prompt_mode),
|
||||||
|
self.prompt_path,
|
||||||
|
argv=full_argv,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# ALWAYS cd into the workdir before exec'ing the agent. The
|
||||||
|
# control SSH logs in as root, so the inherited cwd is /root —
|
||||||
|
# which the agent (running as node) can't even read now that
|
||||||
|
# /root is root-owned. Run the agent from the node workdir
|
||||||
|
# (default /home/node) so its cwd is node-accessible; otherwise
|
||||||
|
# Node's process.cwd(), the shell-snapshot machinery, and
|
||||||
|
# `/doctor` all fail on the unreadable /root.
|
||||||
|
# Run the agent from the node workdir (default /home/node), NOT
|
||||||
|
# the /root cwd inherited from the root SSH login — /root is now
|
||||||
|
# root-owned and unreadable by node, which breaks Node's
|
||||||
|
# process.cwd(), the shell-snapshot machinery, and `/doctor`.
|
||||||
|
# Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'`
|
||||||
|
# wrapper: ssh space-joins everything after the host into one
|
||||||
|
# string for the guest shell, so a quoted script + $@ would be
|
||||||
|
# re-split and mangled (exec'ing the $0 placeholder). All-simple
|
||||||
|
# words survive that join.
|
||||||
|
workdir = self.agent_workdir or _HOME_FOR["node"]
|
||||||
|
remote = ["runuser", "-u", "node", "--",
|
||||||
|
"env", f"--chdir={workdir}",
|
||||||
|
*_env_assignments_for("node", self._guest_env),
|
||||||
|
self.agent_command, *full_argv]
|
||||||
|
return remote
|
||||||
|
|
||||||
|
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
||||||
|
return [*self._ssh(tty=tty), "--", *self._agent_remote_argv(argv)]
|
||||||
|
|
||||||
|
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 the script over stdin (`sh -s`) so nothing needs shell
|
||||||
|
# quoting through the SSH command line.
|
||||||
|
remote = ["runuser", "-u", user, "--",
|
||||||
|
"env", *_env_assignments_for(user, self._guest_env),
|
||||||
|
"/bin/sh", "-s"]
|
||||||
|
result = subprocess.run(
|
||||||
|
[*self._ssh(tty=False), "--", *remote],
|
||||||
|
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:
|
||||||
|
# Stream a tar over the SSH exec channel rather than scp: the
|
||||||
|
# guest runs dropbear, which ships no sftp-server/scp, but every
|
||||||
|
# agent image has `tar`. Copy so `container_path` becomes a copy
|
||||||
|
# of `host_path` (docker cp semantics — callers rm -rf the
|
||||||
|
# destination first).
|
||||||
|
host_parent = os.path.dirname(host_path.rstrip("/")) or "/"
|
||||||
|
host_base = os.path.basename(host_path.rstrip("/"))
|
||||||
|
guest_parent = os.path.dirname(container_path.rstrip("/")) or "/"
|
||||||
|
guest_base = os.path.basename(container_path.rstrip("/"))
|
||||||
|
remote = (
|
||||||
|
f"mkdir -p {shlex.quote(guest_parent)} && "
|
||||||
|
f"cd {shlex.quote(guest_parent)} && "
|
||||||
|
f"rm -rf {shlex.quote(guest_base)} && tar -xf - && "
|
||||||
|
f"{{ [ {shlex.quote(host_base)} = {shlex.quote(guest_base)} ] || "
|
||||||
|
f"mv {shlex.quote(host_base)} {shlex.quote(guest_base)}; }}"
|
||||||
|
)
|
||||||
|
tar = subprocess.Popen(
|
||||||
|
["tar", "-C", host_parent, "-cf", "-", host_base],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
# Pass the command as one arg: ssh space-joins everything after
|
||||||
|
# the host into a single string for the guest's login shell, so a
|
||||||
|
# `sh -c <remote>` split would drop everything past the first word
|
||||||
|
# (the guest shell runs `<remote>` directly; stdin carries the
|
||||||
|
# tar).
|
||||||
|
ssh = subprocess.run(
|
||||||
|
[*self._ssh(tty=False), "--", remote],
|
||||||
|
stdin=tar.stdout, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
tar.wait()
|
||||||
|
if tar.returncode != 0 or ssh.returncode != 0:
|
||||||
|
sys.stderr.write(ssh.stderr)
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
ssh.returncode or tar.returncode,
|
||||||
|
["cp_in", host_path, container_path],
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
# Real teardown (VM terminate, TAP release) lives on the launch
|
||||||
|
# ExitStack; this is the idempotent alias the ABC expects.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Cleanup plan for the Firecracker backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ...log import info
|
||||||
|
from .. import BottleCleanupPlan
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
||||||
|
# PIDs of orphaned firecracker VMM processes and the sidecar
|
||||||
|
# containers left behind by previous bottles.
|
||||||
|
vm_pids: tuple[int, ...] = ()
|
||||||
|
containers: tuple[str, ...] = ()
|
||||||
|
run_dirs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def print(self) -> None:
|
||||||
|
if self.empty:
|
||||||
|
info("firecracker cleanup: nothing to remove")
|
||||||
|
return
|
||||||
|
for pid in self.vm_pids:
|
||||||
|
info(f"firecracker VM process: pid {pid}")
|
||||||
|
for name in self.containers:
|
||||||
|
info(f"firecracker sidecar container: {name}")
|
||||||
|
for path in self.run_dirs:
|
||||||
|
info(f"firecracker run dir: {path}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return not (self.vm_pids or self.containers or self.run_dirs)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Plan type for the Firecracker 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 FirecrackerBottlePlan(BottlePlan):
|
||||||
|
slug: str
|
||||||
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
|
# Stamped by launch once the sidecar is up and its ports are
|
||||||
|
# published on the host-side TAP IP (empty at prepare time).
|
||||||
|
agent_proxy_url: str = ""
|
||||||
|
agent_git_gate_url: str = ""
|
||||||
|
agent_supervise_url: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def container_name(self) -> str:
|
||||||
|
"""Instance name, reused for the sidecar container + VM run dir.
|
||||||
|
Matches the `bot-bottle-<slug>` convention the other backends
|
||||||
|
use so cleanup/enumerate discovery-by-prefix keeps working."""
|
||||||
|
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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Cleanup for the Firecracker backend.
|
||||||
|
|
||||||
|
Orphans are: firecracker VMM processes whose config lives under our run
|
||||||
|
dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run
|
||||||
|
dirs. TAP slots free themselves (the flock drops when the launcher
|
||||||
|
exits), so there is nothing to reclaim there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import info
|
||||||
|
from . import util
|
||||||
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
|
||||||
|
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_root() -> Path:
|
||||||
|
return util.cache_dir() / "run"
|
||||||
|
|
||||||
|
|
||||||
|
def _orphan_vm_pids() -> list[int]:
|
||||||
|
"""firecracker processes whose --config-file is under our run dir."""
|
||||||
|
run_root = str(_run_root())
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-a", "firecracker"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
pids: list[int] = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split(None, 1)
|
||||||
|
if len(parts) != 2 or run_root not in parts[1]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pids.append(int(parts[0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_containers() -> list[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}",
|
||||||
|
"--filter", f"name={_SIDECAR_PREFIX}"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
return sorted(n.strip() for n in result.stdout.splitlines() if n.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dirs() -> list[str]:
|
||||||
|
run_root = _run_root()
|
||||||
|
if not run_root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(str(p) for p in run_root.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
||||||
|
return FirecrackerBottleCleanupPlan(
|
||||||
|
vm_pids=tuple(_orphan_vm_pids()),
|
||||||
|
containers=tuple(_sidecar_containers()),
|
||||||
|
run_dirs=tuple(_run_dirs()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
||||||
|
for pid in plan.vm_pids:
|
||||||
|
info(f"kill firecracker VM pid {pid}")
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
for name in plan.containers:
|
||||||
|
info(f"docker rm -f {name}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
for path in plan.run_dirs:
|
||||||
|
info(f"rm -rf {path}")
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Active-agent enumeration for the Firecracker backend.
|
||||||
|
|
||||||
|
The agent runs in a VM (no container to list), so a live bottle is
|
||||||
|
identified by its running sidecar container `bot-bottle-sidecars-<slug>`
|
||||||
|
— the same discovery-by-prefix the other backends use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ...bottle_state import read_metadata
|
||||||
|
from .. import ActiveAgent
|
||||||
|
|
||||||
|
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
||||||
|
|
||||||
|
|
||||||
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}",
|
||||||
|
"--filter", f"name={_SIDECAR_PREFIX}"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
out: list[ActiveAgent] = []
|
||||||
|
for name in sorted(n.strip() for n in result.stdout.splitlines() if n.strip()):
|
||||||
|
slug = name[len(_SIDECAR_PREFIX):]
|
||||||
|
metadata = read_metadata(slug)
|
||||||
|
if metadata is None or metadata.backend != "firecracker":
|
||||||
|
# Skip sidecars owned by another backend (docker shares the
|
||||||
|
# container-name prefix).
|
||||||
|
continue
|
||||||
|
out.append(ActiveAgent(
|
||||||
|
backend_name="firecracker",
|
||||||
|
slug=slug,
|
||||||
|
agent_name=metadata.agent_name,
|
||||||
|
started_at=metadata.started_at,
|
||||||
|
services=(),
|
||||||
|
label=metadata.label,
|
||||||
|
color=metadata.color,
|
||||||
|
))
|
||||||
|
return out
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Firecracker microVM process lifecycle.
|
||||||
|
|
||||||
|
A bottle VM is one `firecracker` process booted from a JSON config
|
||||||
|
(kernel + writable rootfs ext4 + one TAP network interface). We run it
|
||||||
|
`--no-api`: all configuration is in the config file, and teardown is a
|
||||||
|
process signal — the microVM dies with its VMM, which is exactly the
|
||||||
|
ephemeral-bottle semantics we want. No API socket, no runtime
|
||||||
|
reconfiguration.
|
||||||
|
|
||||||
|
The guest gets its IP from the kernel `ip=` cmdline (kernel-level
|
||||||
|
autoconfig, no in-guest iproute2) and its SSH pubkey from a `bb_pubkey=`
|
||||||
|
cmdline arg the init decodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die, info
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
# Serial console off the critical path but captured to a log for
|
||||||
|
# debugging boot failures. `reboot=k panic=1 pci=off` are the standard
|
||||||
|
# Firecracker guest args; `i8042.*` skip the (absent) PS/2 probe to
|
||||||
|
# shave boot time.
|
||||||
|
_BASE_BOOT_ARGS = (
|
||||||
|
"console=ttyS0 reboot=k panic=1 pci=off "
|
||||||
|
"i8042.noaux i8042.nomux i8042.nopnp i8042.dumbkbd "
|
||||||
|
"root=/dev/vda rw init=/bb-init"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VmHandle:
|
||||||
|
"""A running microVM: its VMM process, guest IP, and console log."""
|
||||||
|
|
||||||
|
process: subprocess.Popen[bytes]
|
||||||
|
guest_ip: str
|
||||||
|
console_log: Path
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
return self.process.poll() is None
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
"""Stop the VMM (and thus the guest). SIGTERM, then SIGKILL."""
|
||||||
|
if self.process.poll() is not None:
|
||||||
|
return
|
||||||
|
self.process.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _boot_args(guest_ip: str, host_ip: str, pubkey: str) -> str:
|
||||||
|
# ip=<client>::<gw>:<netmask>::<dev>:off — /31 point-to-point link,
|
||||||
|
# so netmask is 255.255.255.254 and the gateway is the host TAP IP.
|
||||||
|
ip_arg = f"ip={guest_ip}::{host_ip}:255.255.255.254::eth0:off"
|
||||||
|
pub_b64 = base64.b64encode(pubkey.encode()).decode()
|
||||||
|
return f"{_BASE_BOOT_ARGS} {ip_arg} bb_pubkey={pub_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def _config(
|
||||||
|
*,
|
||||||
|
rootfs: Path,
|
||||||
|
tap: str,
|
||||||
|
guest_ip: str,
|
||||||
|
host_ip: str,
|
||||||
|
pubkey: str,
|
||||||
|
vcpus: int,
|
||||||
|
mem_mib: int,
|
||||||
|
guest_mac: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"boot-source": {
|
||||||
|
"kernel_image_path": str(util.kernel_path()),
|
||||||
|
"boot_args": _boot_args(guest_ip, host_ip, pubkey),
|
||||||
|
},
|
||||||
|
"drives": [
|
||||||
|
{
|
||||||
|
"drive_id": "rootfs",
|
||||||
|
"path_on_host": str(rootfs),
|
||||||
|
"is_root_device": True,
|
||||||
|
"is_read_only": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"network-interfaces": [
|
||||||
|
{
|
||||||
|
"iface_id": "eth0",
|
||||||
|
"host_dev_name": tap,
|
||||||
|
"guest_mac": guest_mac,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"machine-config": {
|
||||||
|
"vcpu_count": vcpus,
|
||||||
|
"mem_size_mib": mem_mib,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def boot(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
rootfs: Path,
|
||||||
|
tap: str,
|
||||||
|
guest_ip: str,
|
||||||
|
host_ip: str,
|
||||||
|
pubkey: str,
|
||||||
|
run_dir: Path,
|
||||||
|
vcpus: int = 2,
|
||||||
|
mem_mib: int = 2048,
|
||||||
|
guest_mac: str = "06:00:AC:10:00:02",
|
||||||
|
) -> VmHandle:
|
||||||
|
"""Write the config and launch the VMM. Returns once the process is
|
||||||
|
spawned; callers wait for SSH readiness separately."""
|
||||||
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path = run_dir / "config.json"
|
||||||
|
console_log = run_dir / "console.log"
|
||||||
|
config_path.write_text(json.dumps(
|
||||||
|
_config(
|
||||||
|
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
|
||||||
|
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
info(f"booting microVM {name} on {tap} (guest {guest_ip})")
|
||||||
|
log_fh = console_log.open("wb")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["firecracker", "--no-api", "--config-file", str(config_path)],
|
||||||
|
stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_ssh(
|
||||||
|
vm: VmHandle, private_key: Path, *, timeout: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
"""Poll SSH until the guest accepts a command or the deadline
|
||||||
|
passes. Dies (with the console tail) if the VMM exits early or the
|
||||||
|
guest never comes up — a boot failure must be loud, not a hang."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
probe = util.ssh_base_argv(private_key, vm.guest_ip) + ["true"]
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not vm.is_alive():
|
||||||
|
die(f"microVM exited during boot (rc={vm.process.returncode}).\n"
|
||||||
|
f"{_console_tail(vm.console_log)}")
|
||||||
|
result = subprocess.run(
|
||||||
|
probe, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
die(f"microVM {vm.guest_ip} did not accept SSH within {timeout:.0f}s.\n"
|
||||||
|
f"{_console_tail(vm.console_log)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _console_tail(console_log: Path, lines: int = 25) -> str:
|
||||||
|
try:
|
||||||
|
text = console_log.read_text(errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "(no console log)"
|
||||||
|
tail = "\n".join(text[-lines:])
|
||||||
|
return f"--- guest console (last {lines} lines) ---\n{tail}"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""FirecrackerFreezer — snapshot a running microVM to a Docker image.
|
||||||
|
|
||||||
|
The VM is live and can't be block-copied safely, so — like the macOS
|
||||||
|
backend — we stream the guest root filesystem out over the control
|
||||||
|
channel (SSH here) and rebuild an image from it. The bottle keeps
|
||||||
|
running after the snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die, info
|
||||||
|
from .. import ActiveAgent
|
||||||
|
from ..freeze import Freezer
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
class FirecrackerFreezer(Freezer):
|
||||||
|
backend_name = "firecracker"
|
||||||
|
|
||||||
|
def _freeze(self, agent: ActiveAgent) -> str:
|
||||||
|
run_dir = util.cache_dir() / "run" / agent.slug
|
||||||
|
private_key = run_dir / "bottle_id_ed25519"
|
||||||
|
guest_ip = _guest_ip_from_config(run_dir / "config.json")
|
||||||
|
if not private_key.is_file() or not guest_ip:
|
||||||
|
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
|
||||||
|
f"SSH key or VM config (is the bottle still running?)")
|
||||||
|
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||||
|
_commit_via_ssh(private_key, guest_ip, image_tag)
|
||||||
|
info(f"committed {agent.slug} -> {image_tag!r}")
|
||||||
|
return image_tag
|
||||||
|
|
||||||
|
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||||
|
info(f"to export for migration: docker image save {image_ref} "
|
||||||
|
f"-o {slug}.tar")
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_ip_from_config(config_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
cfg = json.loads(config_path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return ""
|
||||||
|
boot_args = cfg.get("boot-source", {}).get("boot_args", "")
|
||||||
|
for token in boot_args.split():
|
||||||
|
if token.startswith("ip="):
|
||||||
|
# ip=<client>::<gw>:<mask>::<dev>:off
|
||||||
|
return token[len("ip="):].split(":", 1)[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_via_ssh(private_key: Path, guest_ip: str, image_tag: str) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bot-bottle-fc-commit.") as tmp:
|
||||||
|
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
||||||
|
ssh = util.ssh_base_argv(private_key, guest_ip)
|
||||||
|
with open(rootfs_tar, "wb") as tar_out:
|
||||||
|
result = subprocess.run(
|
||||||
|
[*ssh, "--", "tar", "--create", "--one-file-system",
|
||||||
|
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
||||||
|
"--exclude=./run", "--file=-", "--directory=/", "."],
|
||||||
|
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"ssh tar for {guest_ip} failed: "
|
||||||
|
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
||||||
|
with open(os.path.join(tmp, "Dockerfile"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("FROM scratch\nADD rootfs.tar /\nUSER node\nWORKDIR /home/node\n")
|
||||||
|
build = subprocess.run(
|
||||||
|
["docker", "build", "-t", image_tag, tmp], check=False,
|
||||||
|
)
|
||||||
|
if build.returncode != 0:
|
||||||
|
die(f"docker build for {image_tag!r} failed")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Empirical, post-boot isolation probe — the authoritative fail-closed
|
||||||
|
egress-boundary check.
|
||||||
|
|
||||||
|
The pre-boot nft check can't be trusted on every host (listing an
|
||||||
|
nftables table typically needs root; the launcher runs unprivileged).
|
||||||
|
So before the agent ever runs, we prove the boundary directly: open a
|
||||||
|
canary TCP listener on a host IP the VM must NOT be able to reach (the
|
||||||
|
host's primary non-TAP address), then have the guest try to connect. If
|
||||||
|
the isolation rules are in force the connection is dropped and times
|
||||||
|
out; if it succeeds, the VM can reach host services it shouldn't — a
|
||||||
|
sandbox escape — and we refuse to continue.
|
||||||
|
|
||||||
|
The listener is load-bearing: without it a "blocked" result could just
|
||||||
|
be connection-refused. With it, a reachable canary means a real leak
|
||||||
|
and a timeout means a real drop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
def _host_primary_ip() -> str:
|
||||||
|
"""The source IP the host uses for off-box traffic (its LAN
|
||||||
|
address) — a canary the VM must not reach. Empty if the host has no
|
||||||
|
such route (isolated box)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["ip", "-o", "route", "get", "1.1.1.1"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return ""
|
||||||
|
tokens = result.stdout.split()
|
||||||
|
# `... src <addr> ...` — the address the host would use as source.
|
||||||
|
if "src" in tokens:
|
||||||
|
idx = tokens.index("src")
|
||||||
|
if idx + 1 < len(tokens):
|
||||||
|
return tokens[idx + 1]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# Guest-side connect test: exit 0 = reached the canary (LEAK), 1 =
|
||||||
|
# blocked (good), 2 = no usable tool (inconclusive -> fail-closed).
|
||||||
|
_GUEST_PROBE = r"""
|
||||||
|
h="$1"; p="$2"
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 - "$h" "$p" <<'PY'
|
||||||
|
import socket, sys
|
||||||
|
s = socket.socket(); s.settimeout(3)
|
||||||
|
sys.exit(0 if s.connect_ex((sys.argv[1], int(sys.argv[2]))) == 0 else 1)
|
||||||
|
PY
|
||||||
|
elif command -v bash >/dev/null 2>&1; then
|
||||||
|
timeout 3 bash -c "exec 3<>/dev/tcp/$h/$p" >/dev/null 2>&1
|
||||||
|
elif command -v nc >/dev/null 2>&1; then
|
||||||
|
nc -w3 -z "$h" "$p" >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def verify_isolation(private_key: Path, guest_ip: str) -> None:
|
||||||
|
"""Prove the VM cannot reach the host's primary IP. Dies
|
||||||
|
(fail-closed) on a confirmed leak or if the guest has no tool to
|
||||||
|
run the test. Warns (does not fail) when the host has no canary
|
||||||
|
address to test against."""
|
||||||
|
canary_ip = _host_primary_ip()
|
||||||
|
if not canary_ip:
|
||||||
|
warn("isolation probe skipped: host has no non-TAP route to test "
|
||||||
|
"against (isolated box).")
|
||||||
|
return
|
||||||
|
|
||||||
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
listener.bind((canary_ip, 0))
|
||||||
|
listener.listen(1)
|
||||||
|
listener.settimeout(6)
|
||||||
|
canary_port = listener.getsockname()[1]
|
||||||
|
|
||||||
|
accepted: list[bool] = []
|
||||||
|
|
||||||
|
def _accept() -> None:
|
||||||
|
try:
|
||||||
|
conn, _ = listener.accept()
|
||||||
|
accepted.append(True)
|
||||||
|
conn.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_accept, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
ssh = util.ssh_base_argv(private_key, guest_ip)
|
||||||
|
result = subprocess.run(
|
||||||
|
[*ssh, "--", "sh", "-s", "--", canary_ip, str(canary_port)],
|
||||||
|
input=_GUEST_PROBE, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
thread.join(timeout=7)
|
||||||
|
listener.close()
|
||||||
|
|
||||||
|
if result.returncode == 0 or accepted:
|
||||||
|
die(f"ISOLATION FAILURE: the VM reached the host canary "
|
||||||
|
f"{canary_ip}:{canary_port}. The egress boundary is not in "
|
||||||
|
f"force — refusing to run the agent (fail-closed). Verify the "
|
||||||
|
f"nft table with: ./cli.py backend setup --backend=firecracker")
|
||||||
|
if result.returncode == 2:
|
||||||
|
die("isolation probe inconclusive: the guest has no python3/bash/nc "
|
||||||
|
"to run the connectivity test. Refusing to continue "
|
||||||
|
"(fail-closed).")
|
||||||
|
info(f"isolation verified: VM cannot reach host {canary_ip}")
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""Launch flow for the Firecracker backend.
|
||||||
|
|
||||||
|
Per bottle:
|
||||||
|
1. mint the egress CA, build the agent image (docker), export it to a
|
||||||
|
cached ext4 rootfs;
|
||||||
|
2. claim a free TAP pool slot (rootless flock);
|
||||||
|
3. bring up the Docker sidecar bundle, publishing egress / git-gate /
|
||||||
|
supervise on the slot's host-side TAP IP at fixed ports;
|
||||||
|
4. boot the microVM on that TAP; wait for SSH;
|
||||||
|
5. provision (CA, prompt, skills, workspace, git, supervise) over SSH.
|
||||||
|
|
||||||
|
Isolation is enforced by the operator-provisioned nft table (checked
|
||||||
|
fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from
|
||||||
|
the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore
|
||||||
|
points at `http://<host_tap_ip>:9099`, its only route to the world.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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_agent_env_entries,
|
||||||
|
egress_resolve_token_values,
|
||||||
|
egress_sidecar_env_entries,
|
||||||
|
)
|
||||||
|
from ...git_gate import (
|
||||||
|
provision_git_gate_dynamic_keys,
|
||||||
|
revoke_git_gate_provisioned_keys,
|
||||||
|
)
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
||||||
|
from ...util import expand_tilde
|
||||||
|
from ..docker.egress import (
|
||||||
|
EGRESS_CA_IN_CONTAINER,
|
||||||
|
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 ..docker.sidecar_bundle import (
|
||||||
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
|
)
|
||||||
|
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
|
from . import firecracker_vm, isolation_probe, netpool, util
|
||||||
|
from .bottle import FirecrackerBottle
|
||||||
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
_GIT_HTTP_PORT = 9420
|
||||||
|
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
||||||
|
|
||||||
|
|
||||||
|
def sidecar_container_name(slug: str) -> str:
|
||||||
|
return f"bot-bottle-sidecars-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def launch(
|
||||||
|
plan: FirecrackerBottlePlan,
|
||||||
|
*,
|
||||||
|
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
||||||
|
) -> Generator[FirecrackerBottle, None, None]:
|
||||||
|
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"firecracker 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_agent_image(plan)
|
||||||
|
|
||||||
|
# Claim a TAP slot; the flock is held until teardown closes it.
|
||||||
|
slot, lock = netpool.allocate(plan.slug)
|
||||||
|
stack.callback(lock.close)
|
||||||
|
info(f"firecracker slot {slot.iface}: host={slot.host_ip} "
|
||||||
|
f"guest={slot.guest_ip}")
|
||||||
|
|
||||||
|
plan = _provision_git_gate_keys(plan)
|
||||||
|
|
||||||
|
sidecar_name = sidecar_container_name(plan.slug)
|
||||||
|
_force_remove_container(sidecar_name)
|
||||||
|
_start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
|
||||||
|
stack.callback(_force_remove_container, sidecar_name)
|
||||||
|
_stage_git_gate(plan, sidecar_name)
|
||||||
|
|
||||||
|
plan = _stamp_agent_urls(plan, slot.host_ip)
|
||||||
|
|
||||||
|
# Build the per-bottle rootfs + SSH key, then boot.
|
||||||
|
base_dir = util.build_base_rootfs_dir(plan.image)
|
||||||
|
run_dir = util.cache_dir() / "run" / plan.slug
|
||||||
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
rootfs = run_dir / "rootfs.ext4"
|
||||||
|
util.build_rootfs_ext4(base_dir, rootfs)
|
||||||
|
private_key, pubkey = util.generate_keypair(run_dir)
|
||||||
|
|
||||||
|
vm = firecracker_vm.boot(
|
||||||
|
name=plan.container_name,
|
||||||
|
rootfs=rootfs,
|
||||||
|
tap=slot.iface,
|
||||||
|
guest_ip=slot.guest_ip,
|
||||||
|
host_ip=slot.host_ip,
|
||||||
|
pubkey=pubkey,
|
||||||
|
run_dir=run_dir,
|
||||||
|
)
|
||||||
|
stack.callback(vm.terminate)
|
||||||
|
firecracker_vm.wait_for_ssh(vm, private_key)
|
||||||
|
|
||||||
|
# Authoritative fail-closed egress-boundary check, before the
|
||||||
|
# agent runs: prove the VM cannot reach the host directly.
|
||||||
|
isolation_probe.verify_isolation(private_key, slot.guest_ip)
|
||||||
|
|
||||||
|
bottle = FirecrackerBottle(
|
||||||
|
plan.container_name,
|
||||||
|
private_key=private_key,
|
||||||
|
guest_ip=slot.guest_ip,
|
||||||
|
guest_env=_agent_guest_env(plan, slot.host_ip),
|
||||||
|
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: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
||||||
|
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_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
||||||
|
_docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||||
|
committed = read_committed_image(plan.slug)
|
||||||
|
if committed and _image_exists(committed):
|
||||||
|
info(f"using committed image {committed!r}")
|
||||||
|
return dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||||
|
)
|
||||||
|
_docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_git_gate_keys(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
||||||
|
if not plan.git_gate_plan.upstreams:
|
||||||
|
return plan
|
||||||
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
|
plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug),
|
||||||
|
)
|
||||||
|
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_agent_urls(
|
||||||
|
plan: FirecrackerBottlePlan, host_ip: str,
|
||||||
|
) -> FirecrackerBottlePlan:
|
||||||
|
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
||||||
|
supervise_url = (
|
||||||
|
f"http://{host_ip}:{SUPERVISE_PORT}/" if plan.supervise_plan is not None else ""
|
||||||
|
)
|
||||||
|
git_gate_url = (
|
||||||
|
f"http://{host_ip}:{_GIT_HTTP_PORT}" if plan.git_gate_plan.upstreams else ""
|
||||||
|
)
|
||||||
|
return dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_proxy_url=proxy_url,
|
||||||
|
agent_git_gate_url=git_gate_url,
|
||||||
|
agent_supervise_url=supervise_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- sidecar bundle (Docker) ----------------------------------------
|
||||||
|
|
||||||
|
def _start_sidecar_bundle(
|
||||||
|
plan: FirecrackerBottlePlan, sidecar_name: str, host_ip: str,
|
||||||
|
) -> None:
|
||||||
|
argv = ["docker", "run", "--name", sidecar_name, "--detach", "--rm",
|
||||||
|
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}"]
|
||||||
|
for entry in _sidecar_env_entries(plan):
|
||||||
|
argv += ["-e", entry]
|
||||||
|
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
||||||
|
argv += ["-v", f"{host_path}:{container_path}{':ro' if read_only else ''}"]
|
||||||
|
# Publish on the slot's host TAP IP at fixed ports — each bottle
|
||||||
|
# has a distinct host_ip, so fixed ports never collide, and the VM
|
||||||
|
# reaches them at a stable, well-known address (its only route out).
|
||||||
|
for port in _sidecar_ports(plan):
|
||||||
|
argv += ["-p", f"{host_ip}:{port}:{port}"]
|
||||||
|
argv.append(SIDECAR_BUNDLE_IMAGE)
|
||||||
|
|
||||||
|
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"docker run sidecar bundle {sidecar_name} (published on {host_ip})")
|
||||||
|
result = subprocess.run(argv, capture_output=True, text=True, env=env, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker run for sidecar bundle {sidecar_name} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_daemons(plan: FirecrackerBottlePlan) -> 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_ports(plan: FirecrackerBottlePlan) -> tuple[int, ...]:
|
||||||
|
ports = [EGRESS_PORT]
|
||||||
|
if plan.git_gate_plan.upstreams:
|
||||||
|
ports.append(_GIT_HTTP_PORT)
|
||||||
|
if plan.supervise_plan is not None:
|
||||||
|
ports.append(SUPERVISE_PORT)
|
||||||
|
return tuple(ports)
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_env_entries(plan: FirecrackerBottlePlan) -> tuple[str, ...]:
|
||||||
|
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
|
||||||
|
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_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
|
]
|
||||||
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_mounts(
|
||||||
|
plan: FirecrackerBottlePlan,
|
||||||
|
) -> 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.db_path.parent),
|
||||||
|
str(Path(DB_PATH_IN_CONTAINER).parent), False))
|
||||||
|
return tuple(mounts)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
|
||||||
|
gp = plan.git_gate_plan
|
||||||
|
if not gp.upstreams:
|
||||||
|
return
|
||||||
|
_docker_exec(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):
|
||||||
|
_docker_cp(host_path, f"{sidecar_name}:{container_path}")
|
||||||
|
_docker_exec(sidecar_name, [
|
||||||
|
"sh", "-c",
|
||||||
|
f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
||||||
|
f"{GIT_GATE_HOOK_IN_CONTAINER} {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: FirecrackerBottlePlan) -> 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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- agent guest env -------------------------------------------------
|
||||||
|
|
||||||
|
def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str]:
|
||||||
|
"""Env injected into every agent/exec call over SSH. The VM has no
|
||||||
|
baked process env (it just runs init), so the proxy/CA/git/supervise
|
||||||
|
wiring is applied per-invocation."""
|
||||||
|
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
||||||
|
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
||||||
|
env: dict[str, str] = {
|
||||||
|
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
||||||
|
"https_proxy": proxy_url, "http_proxy": proxy_url,
|
||||||
|
"NO_PROXY": no_proxy, "no_proxy": no_proxy,
|
||||||
|
"NODE_EXTRA_CA_CERTS": AGENT_CA_PATH,
|
||||||
|
"SSL_CERT_FILE": AGENT_CA_BUNDLE,
|
||||||
|
"REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE,
|
||||||
|
}
|
||||||
|
if plan.agent_git_gate_url:
|
||||||
|
env["GIT_GATE_URL"] = plan.agent_git_gate_url
|
||||||
|
if plan.agent_supervise_url:
|
||||||
|
env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url
|
||||||
|
for entry in egress_agent_env_entries(plan.egress_plan):
|
||||||
|
key, _, value = entry.partition("=")
|
||||||
|
env[key] = value
|
||||||
|
env.update(plan.agent_provision.guest_env)
|
||||||
|
# Forwarded (bare-name) env: resolve host values now, since the VM
|
||||||
|
# can't inherit them from a `docker run --env NAME`.
|
||||||
|
for name in plan.forwarded_env:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is not None:
|
||||||
|
env[name] = value
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# --- docker helpers --------------------------------------------------
|
||||||
|
|
||||||
|
def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||||
|
info(f"docker build {ref}")
|
||||||
|
args = ["docker", "build", "-t", ref]
|
||||||
|
if dockerfile:
|
||||||
|
if not os.path.isabs(dockerfile):
|
||||||
|
dockerfile = os.path.join(context, dockerfile)
|
||||||
|
args += ["-f", dockerfile]
|
||||||
|
args.append(context)
|
||||||
|
result = subprocess.run(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker build for {ref!r} failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _image_exists(ref: str) -> bool:
|
||||||
|
return subprocess.run(
|
||||||
|
["docker", "image", "inspect", ref],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _force_remove_container(name: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_exec(name: str, argv: list[str]) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", name, *argv], capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker exec in {name} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_cp(host_path: str, dest: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "cp", host_path, dest], capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker cp {host_path} -> {dest} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Firecracker network pool: constants, IP math, allocation, and the
|
||||||
|
config renderers (shell command + NixOS module) shown to operators.
|
||||||
|
|
||||||
|
The Firecracker backend needs a privileged one-time network setup:
|
||||||
|
a pool of point-to-point TAP devices (owned by the invoking user, so
|
||||||
|
`./cli.py start` never needs root) and a dedicated nftables table that
|
||||||
|
isolates every VM. This module is the single source of truth for the
|
||||||
|
pool parameters — the shell script (`scripts/firecracker-netpool.sh`),
|
||||||
|
the NixOS module (`nix/firecracker-netpool.nix`), and the backend's
|
||||||
|
fail-closed preflight all derive from these constants so they can't
|
||||||
|
drift.
|
||||||
|
|
||||||
|
Topology (per slot i):
|
||||||
|
* TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0
|
||||||
|
/ br-* collisions.
|
||||||
|
* a /31 host<->guest link: host = base + 2i (the gateway the VM
|
||||||
|
routes through), guest = base + 2i + 1 (the VM's address).
|
||||||
|
* isolation via ``table inet bot_bottle_fc``: a VM reaches only its
|
||||||
|
own sidecar (DNAT'd from the host TAP IP) and nothing else.
|
||||||
|
|
||||||
|
The default IP block is ``10.243.0.0/16`` — an intentionally obscure
|
||||||
|
corner of RFC-1918 private space. RFC-1918 is the range *designated*
|
||||||
|
for private links; we pick a high, unusual /16 to steer clear of the
|
||||||
|
common occupants (Docker's 172.17-31, libvirt's 192.168.122, k8s
|
||||||
|
10.42/10.244, and typical home LANs on 192.168.0/1.x or 10.0.0.x).
|
||||||
|
We deliberately avoid ``100.64.0.0/10`` (RFC-6598 CGNAT) because
|
||||||
|
Tailscale hands out node addresses from exactly that range. No default
|
||||||
|
is collision-proof, so `overlapping_routes()` is the real guard —
|
||||||
|
`backend setup`/`status` and the launch preflight call it to catch
|
||||||
|
a base that clashes with something already on the host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
|
# Interface names are capped at 15 chars (IFNAMSIZ-1); "bbfc" + a small
|
||||||
|
# index stays well under that and is distinctive enough to grep for.
|
||||||
|
IFACE_PREFIX = os.environ.get("BOT_BOTTLE_FC_IFACE_PREFIX", "bbfc")
|
||||||
|
NFT_TABLE = "bot_bottle_fc"
|
||||||
|
|
||||||
|
|
||||||
|
def pool_size() -> int:
|
||||||
|
return int(os.environ.get("BOT_BOTTLE_FC_POOL_SIZE", "8"))
|
||||||
|
|
||||||
|
|
||||||
|
def ip_base() -> str:
|
||||||
|
return os.environ.get("BOT_BOTTLE_FC_IP_BASE", "10.243.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
|
||||||
|
# with the backend constants (egress 9099, supervise 9100, git-http
|
||||||
|
# 9420); rendered into the setup output for operator visibility.
|
||||||
|
SIDECAR_PORTS = (9099, 9100, 9420)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Slot:
|
||||||
|
"""One pool slot: a TAP device and its host/guest /31 addresses."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
iface: str
|
||||||
|
host_ip: str
|
||||||
|
guest_ip: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def guest_cidr(self) -> str:
|
||||||
|
"""Guest address with the /31 prefix, for the kernel `ip=` arg."""
|
||||||
|
return f"{self.guest_ip}/31"
|
||||||
|
|
||||||
|
|
||||||
|
def slot(index: int) -> Slot:
|
||||||
|
base = int(ipaddress.IPv4Address(ip_base()))
|
||||||
|
return Slot(
|
||||||
|
index=index,
|
||||||
|
iface=f"{IFACE_PREFIX}{index}",
|
||||||
|
host_ip=str(ipaddress.IPv4Address(base + 2 * index)),
|
||||||
|
guest_ip=str(ipaddress.IPv4Address(base + 2 * index + 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def all_slots() -> list[Slot]:
|
||||||
|
return [slot(i) for i in range(pool_size())]
|
||||||
|
|
||||||
|
|
||||||
|
# --- fail-closed verification ---------------------------------------
|
||||||
|
|
||||||
|
def _run_ok(argv: list[str]) -> bool:
|
||||||
|
"""Run a probe command, treating a missing binary as failure
|
||||||
|
(rather than crashing) so callers can stay fail-closed."""
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def nft_table_present() -> bool:
|
||||||
|
"""True iff the isolation table exists in the active nftables
|
||||||
|
backend. The backend's preflight treats absence as fatal — the VM
|
||||||
|
must not boot without its egress boundary in place.
|
||||||
|
|
||||||
|
Listing may require root; when it can't be confirmed here the
|
||||||
|
launch path falls back to an empirical post-boot isolation probe.
|
||||||
|
Returns False (fail-closed) if `nft` is unavailable."""
|
||||||
|
return _run_ok(["nft", "list", "table", "inet", NFT_TABLE])
|
||||||
|
|
||||||
|
|
||||||
|
def tap_present(iface: str) -> bool:
|
||||||
|
# `ip link show` is unprivileged, so the TAP-pool check is reliable
|
||||||
|
# for the non-root launcher.
|
||||||
|
return _run_ok(["ip", "link", "show", iface])
|
||||||
|
|
||||||
|
|
||||||
|
def missing_taps() -> list[str]:
|
||||||
|
"""Pool TAPs that the one-time setup has not created yet."""
|
||||||
|
return [s.iface for s in all_slots() if not tap_present(s.iface)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteConflict:
|
||||||
|
"""An existing host route whose destination overlaps the pool range
|
||||||
|
but isn't one of our own bbfc TAPs — i.e. the chosen IP base clashes
|
||||||
|
with something already on the host (a Tailscale CGNAT peer, a
|
||||||
|
docker/libvirt bridge, the LAN…)."""
|
||||||
|
|
||||||
|
dst: str
|
||||||
|
dev: str
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_span() -> tuple[int, int]:
|
||||||
|
"""Inclusive [first, last] integer bounds of every address the pool
|
||||||
|
occupies: base .. base + 2*pool_size - 1."""
|
||||||
|
base = int(ipaddress.IPv4Address(ip_base()))
|
||||||
|
return base, base + 2 * pool_size() - 1
|
||||||
|
|
||||||
|
|
||||||
|
def overlapping_routes() -> list[RouteConflict]:
|
||||||
|
"""Existing routes whose destination overlaps the pool's address
|
||||||
|
range, excluding our own `bbfc*` TAP routes and the default route.
|
||||||
|
|
||||||
|
A non-empty result means `BOT_BOTTLE_FC_IP_BASE` collides with
|
||||||
|
something already configured on this host, so the pool would shadow
|
||||||
|
(or be shadowed by) it. Empty on success — or when `ip` is missing
|
||||||
|
or unparseable, since this is an advisory guard, not the fail-closed
|
||||||
|
isolation check (that's the nft table + post-boot probe)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ip", "-json", "route", "show", "table", "all"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
if proc.returncode != 0 or not proc.stdout.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
routes = json.loads(proc.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lo, hi = _pool_span()
|
||||||
|
conflicts: list[RouteConflict] = []
|
||||||
|
for r in routes:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
dst = r.get("dst")
|
||||||
|
dev = r.get("dev", "")
|
||||||
|
if not isinstance(dst, str) or dst in ("", "default"):
|
||||||
|
continue
|
||||||
|
if isinstance(dev, str) and dev.startswith(IFACE_PREFIX):
|
||||||
|
continue # our own pool link
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(dst, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if net.version != 4:
|
||||||
|
continue
|
||||||
|
r_lo = int(net.network_address)
|
||||||
|
r_hi = int(net.broadcast_address)
|
||||||
|
if r_lo <= hi and lo <= r_hi: # ranges intersect
|
||||||
|
conflicts.append(RouteConflict(dst=dst, dev=str(dev)))
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# --- allocation ------------------------------------------------------
|
||||||
|
|
||||||
|
def _lock_dir() -> Path:
|
||||||
|
d = Path.home() / ".cache" / "bot-bottle" / "firecracker" / "pool"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def allocate(slug: str) -> tuple[Slot, IO[str]]:
|
||||||
|
"""Claim a free pool slot for one bottle. Returns the slot and the
|
||||||
|
held lock file — the caller keeps it open for the VM's lifetime and
|
||||||
|
closes it on teardown; the flock auto-releases if the launcher
|
||||||
|
crashes, so a slot is never leaked. Dies when the pool is
|
||||||
|
exhausted.
|
||||||
|
|
||||||
|
A per-slot `flock` (rather than inspecting running processes) makes
|
||||||
|
allocation race-free across concurrent launches without a central
|
||||||
|
registry: the first launcher to grab the lock owns the slot."""
|
||||||
|
del slug # logged by the caller; allocation is purely lock-driven
|
||||||
|
for s in all_slots():
|
||||||
|
lock_path = _lock_dir() / f"{s.iface}.lock"
|
||||||
|
handle = open(lock_path, "w", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
handle.close()
|
||||||
|
continue
|
||||||
|
return s, handle
|
||||||
|
die(f"Firecracker TAP pool exhausted ({pool_size()} slots, all in "
|
||||||
|
f"use). Stop a running bottle or raise BOT_BOTTLE_FC_POOL_SIZE "
|
||||||
|
f"and re-run `./cli.py backend setup --backend=firecracker`.")
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
# --- config renderers (shown by `./cli.py backend setup`) -----------
|
||||||
|
|
||||||
|
# The persistent unit is the portable install: the same systemd oneshot
|
||||||
|
# on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…).
|
||||||
|
SYSTEMD_UNIT = "bot-bottle-firecracker-netpool.service"
|
||||||
|
|
||||||
|
|
||||||
|
def render_shell_setup() -> str:
|
||||||
|
"""The imperative one-shot command — non-persistent fallback for
|
||||||
|
hosts without systemd (OpenRC/runit/manual)."""
|
||||||
|
env = _nondefault_env()
|
||||||
|
prefix = f"{env} " if env else ""
|
||||||
|
return f"sudo {prefix}./scripts/firecracker-netpool.sh up"
|
||||||
|
|
||||||
|
|
||||||
|
def render_systemd_unit(owner: str, script_path: str) -> str:
|
||||||
|
"""A portable systemd oneshot unit for the pool — identical across
|
||||||
|
every systemd distro. ExecStart/ExecStop delegate to the bundled
|
||||||
|
shell script (the single source of bring-up logic); pool params are
|
||||||
|
pinned via Environment= so the unit matches the CLI's current
|
||||||
|
settings and doesn't depend on $SUDO_USER at boot (systemd runs it
|
||||||
|
as root with no SUDO_USER, which would otherwise own the TAPs as
|
||||||
|
root and break the rootless launch)."""
|
||||||
|
return f"""[Unit]
|
||||||
|
Description=bot-bottle Firecracker TAP pool + nft isolation table
|
||||||
|
After=network-pre.target
|
||||||
|
Wants=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
Environment=BOT_BOTTLE_FC_POOL_SIZE={pool_size()}
|
||||||
|
Environment=BOT_BOTTLE_FC_IP_BASE={ip_base()}
|
||||||
|
Environment=BOT_BOTTLE_FC_IFACE_PREFIX={IFACE_PREFIX}
|
||||||
|
Environment=BOT_BOTTLE_FC_OWNER={owner}
|
||||||
|
ExecStart={script_path} up
|
||||||
|
ExecStop={script_path} down
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# The NixOS setup is a real, importable module (nix/firecracker-netpool.nix,
|
||||||
|
# exposed as the flake output nixosModules.firecracker-netpool) rather than a
|
||||||
|
# generated paste — see `backend setup` output.
|
||||||
|
|
||||||
|
|
||||||
|
def _nondefault_env() -> str:
|
||||||
|
"""Render any non-default pool env overrides so the printed shell
|
||||||
|
command reproduces the operator's current settings."""
|
||||||
|
pairs = []
|
||||||
|
if os.environ.get("BOT_BOTTLE_FC_POOL_SIZE"):
|
||||||
|
pairs.append(f"BOT_BOTTLE_FC_POOL_SIZE={pool_size()}")
|
||||||
|
if os.environ.get("BOT_BOTTLE_FC_IP_BASE"):
|
||||||
|
pairs.append(f"BOT_BOTTLE_FC_IP_BASE={ip_base()}")
|
||||||
|
if os.environ.get("BOT_BOTTLE_FC_IFACE_PREFIX"):
|
||||||
|
pairs.append(f"BOT_BOTTLE_FC_IFACE_PREFIX={IFACE_PREFIX}")
|
||||||
|
return " ".join(pairs)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Prepare step for the Firecracker 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 ...manifest import Manifest
|
||||||
|
from ...supervise import SupervisePlan
|
||||||
|
from .. import BottleSpec
|
||||||
|
from . import util
|
||||||
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
def preflight() -> None:
|
||||||
|
util.require_firecracker()
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> FirecrackerBottlePlan:
|
||||||
|
return FirecrackerBottlePlan(
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Host setup + status for the Firecracker backend.
|
||||||
|
|
||||||
|
`setup()` prints the host-appropriate config for the privileged,
|
||||||
|
one-time network pool (TAP devices + isolation nftables table) the
|
||||||
|
backend needs. On NixOS it points at the flake module (and prints a
|
||||||
|
paste-able fallback); elsewhere it prints the sudo command for the
|
||||||
|
bundled setup script. `status()` reports what's present, including
|
||||||
|
whether the pool range collides with an existing route.
|
||||||
|
|
||||||
|
Called through `FirecrackerBottleBackend.setup` / `.status`, which the
|
||||||
|
generic `./cli.py backend {setup,status}` command dispatches to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import netpool
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
_FC_RELEASES = "https://github.com/firecracker-microvm/firecracker/releases"
|
||||||
|
_UNIT_PATH = Path("/etc/systemd/system") / netpool.SYSTEMD_UNIT
|
||||||
|
|
||||||
|
|
||||||
|
def _owner() -> str:
|
||||||
|
# Under `sudo`, USER is root but SUDO_USER is the real invoker — the
|
||||||
|
# TAPs must be owned by them so `./cli.py start` stays rootless.
|
||||||
|
return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_systemd() -> bool:
|
||||||
|
return Path("/run/systemd/system").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def _module_path() -> str:
|
||||||
|
"""Absolute path to the importable NixOS module in this checkout."""
|
||||||
|
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
||||||
|
|
||||||
|
|
||||||
|
def _script_path() -> str:
|
||||||
|
"""Absolute path to the bundled bring-up script in this checkout."""
|
||||||
|
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_prereqs() -> None:
|
||||||
|
"""The firecracker binary + KVM + guest artifacts, shown before the
|
||||||
|
privileged network-pool step so operators see the full picture."""
|
||||||
|
fc = shutil.which("firecracker")
|
||||||
|
if fc:
|
||||||
|
sys.stderr.write(f"1) firecracker binary: found ({fc}).\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(
|
||||||
|
"1) firecracker binary: NOT found on PATH. Install a release binary "
|
||||||
|
"and put it on PATH:\n"
|
||||||
|
f" {_FC_RELEASES}\n"
|
||||||
|
" e.g.: download firecracker-vX.Y.Z-$(uname -m).tgz, extract, and\n"
|
||||||
|
" install -m755 release-*/firecracker-* ~/.local/bin/firecracker\n"
|
||||||
|
" (NixOS: not packaged as a user binary — fetch the release, pin\n"
|
||||||
|
" the version, and add it to PATH.)\n"
|
||||||
|
)
|
||||||
|
if util.is_host_capable():
|
||||||
|
sys.stderr.write(" KVM: /dev/kvm present.\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(
|
||||||
|
" KVM: /dev/kvm missing/unusable — load kvm-intel/kvm-amd, enable\n"
|
||||||
|
" virtualization in firmware, and add your user to the `kvm` group.\n"
|
||||||
|
)
|
||||||
|
sys.stderr.write(
|
||||||
|
" Guest artifacts: a kernel (BOT_BOTTLE_FC_KERNEL) and static dropbear\n"
|
||||||
|
" (BOT_BOTTLE_FC_DROPBEAR) must be cached, and `mke2fs` (e2fsprogs) is\n"
|
||||||
|
" needed to build the rootfs.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nixos() -> bool:
|
||||||
|
if Path("/etc/NIXOS").exists():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return "ID=nixos" in Path("/etc/os-release").read_text()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_overlaps() -> None:
|
||||||
|
"""Warn if the chosen pool range collides with an existing host route
|
||||||
|
(Tailscale CGNAT peer, a docker/libvirt bridge, the LAN…)."""
|
||||||
|
conflicts = netpool.overlapping_routes()
|
||||||
|
if not conflicts:
|
||||||
|
return
|
||||||
|
detail = "\n".join(f" {c.dst} dev {c.dev}" for c in conflicts)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"WARNING: pool range (base {netpool.ip_base()}, "
|
||||||
|
f"{netpool.pool_size()} slots) overlaps existing routes:\n"
|
||||||
|
f"{detail}\n"
|
||||||
|
"Set BOT_BOTTLE_FC_IP_BASE to a free range before setup, or the "
|
||||||
|
"pool may shadow / be shadowed by the above.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup() -> int:
|
||||||
|
sys.stderr.write("Firecracker backend — one-time host setup.\n\n")
|
||||||
|
_print_prereqs()
|
||||||
|
slots = netpool.all_slots()
|
||||||
|
sys.stderr.write(
|
||||||
|
f"2) network pool: {len(slots)} slots "
|
||||||
|
f"({slots[0].iface}..{slots[-1].iface}), base {netpool.ip_base()} — "
|
||||||
|
f"TAP devices + nft isolation table, privileged (needs root once).\n\n"
|
||||||
|
)
|
||||||
|
_warn_overlaps()
|
||||||
|
if _is_nixos():
|
||||||
|
sys.stderr.write(
|
||||||
|
"Detected NixOS. Import the module — it is NON-INVASIVE: it does "
|
||||||
|
"not flip networking.nftables.enable or systemd.network.enable, so "
|
||||||
|
"your existing (iptables) firewall and Docker are untouched. A "
|
||||||
|
"systemd oneshot brings the pool up alongside them.\n\n"
|
||||||
|
" # flake users:\n"
|
||||||
|
" inputs.bot-bottle.url = \"git+ssh://<your-bot-bottle-remote>\";\n"
|
||||||
|
" imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ];\n"
|
||||||
|
" # channel (non-flake) users — import the file directly:\n"
|
||||||
|
f" imports = [ {_module_path()} ];\n\n"
|
||||||
|
f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n"
|
||||||
|
"Then `nixos-rebuild switch`.\n"
|
||||||
|
)
|
||||||
|
elif _has_systemd():
|
||||||
|
_setup_systemd()
|
||||||
|
else:
|
||||||
|
sys.stderr.write(
|
||||||
|
"No systemd detected. Run the one-time bring-up as root (and add "
|
||||||
|
"your own boot persistence — e.g. an OpenRC/runit service):\n\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write(netpool.render_shell_setup() + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_systemd() -> None:
|
||||||
|
"""Install the pool as a persistent systemd unit — the portable path,
|
||||||
|
identical on every systemd distro. Performs the install directly when
|
||||||
|
run as root; otherwise prints a self-contained copy-paste block."""
|
||||||
|
unit = netpool.render_systemd_unit(_owner(), _script_path())
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Persistent install (systemd — same on every systemd distro). Needs "
|
||||||
|
f"`nft` (nftables) and `ip` (iproute2); install via your package "
|
||||||
|
f"manager if `backend status` reports them missing.\n\n"
|
||||||
|
)
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
_UNIT_PATH.write_text(unit)
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||||
|
rc = subprocess.run(
|
||||||
|
["systemctl", "enable", "--now", netpool.SYSTEMD_UNIT], check=False,
|
||||||
|
).returncode
|
||||||
|
if rc == 0:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with "
|
||||||
|
f"`./cli.py backend status --backend=firecracker`.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Wrote {_UNIT_PATH} but `systemctl enable --now` failed — "
|
||||||
|
f"check `systemctl status {netpool.SYSTEMD_UNIT}`.\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
sys.stderr.write(
|
||||||
|
"Install the unit (one copy-paste; enables it on boot too):\n\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write(
|
||||||
|
f"sudo tee {_UNIT_PATH} >/dev/null <<'UNIT'\n"
|
||||||
|
f"{unit}"
|
||||||
|
f"UNIT\n"
|
||||||
|
f"sudo systemctl daemon-reload\n"
|
||||||
|
f"sudo systemctl enable --now {netpool.SYSTEMD_UNIT}\n"
|
||||||
|
)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"\n(Or re-run this as root to install it directly: "
|
||||||
|
f"sudo ./cli.py backend setup --backend=firecracker)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown() -> int:
|
||||||
|
slots = netpool.all_slots()
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Undo the Firecracker network pool ({len(slots)} slots, base "
|
||||||
|
f"{netpool.ip_base()}) — a privileged operation.\n\n"
|
||||||
|
)
|
||||||
|
if _is_nixos():
|
||||||
|
sys.stderr.write(
|
||||||
|
"On NixOS: set `services.bot-bottle-firecracker.enable = false;` "
|
||||||
|
"(or drop the module import) and `nixos-rebuild switch`. The TAP "
|
||||||
|
"netdevs and nft table are removed declaratively.\n\n"
|
||||||
|
"To tear down imperatively before a rebuild (does not persist):\n\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
|
||||||
|
return 0
|
||||||
|
if _has_systemd():
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "disable", "--now", netpool.SYSTEMD_UNIT],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
_UNIT_PATH.unlink(missing_ok=True)
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Stopped, disabled, and removed {netpool.SYSTEMD_UNIT}.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Remove the persistent unit (one copy-paste):\n\n")
|
||||||
|
sys.stdout.write(
|
||||||
|
f"sudo systemctl disable --now {netpool.SYSTEMD_UNIT}\n"
|
||||||
|
f"sudo rm -f {_UNIT_PATH}\n"
|
||||||
|
f"sudo systemctl daemon-reload\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
sys.stderr.write("Run the teardown as root:\n\n")
|
||||||
|
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> int:
|
||||||
|
# Readiness == what the launch preflight hard-requires: the TAP pool
|
||||||
|
# present (unprivileged, authoritative) and no range overlap. Listing
|
||||||
|
# the nft table usually needs root, so — like the preflight — an
|
||||||
|
# unconfirmable table is reported but NOT treated as not-ready; the
|
||||||
|
# post-boot isolation probe is the authoritative check. This keeps an
|
||||||
|
# unprivileged `backend status` usable as a launch gate.
|
||||||
|
ok = True
|
||||||
|
missing = netpool.missing_taps()
|
||||||
|
total = netpool.pool_size()
|
||||||
|
if missing:
|
||||||
|
sys.stderr.write(f"TAP pool: {total - len(missing)}/{total} present "
|
||||||
|
f"(missing: {', '.join(missing)})\n")
|
||||||
|
ok = False
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"TAP pool: {total}/{total} present\n")
|
||||||
|
if shutil.which("nft") is None:
|
||||||
|
sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: unverified "
|
||||||
|
f"(nft not on PATH; enforced + checked post-boot)\n")
|
||||||
|
elif netpool.nft_table_present():
|
||||||
|
sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: present\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: not confirmable "
|
||||||
|
f"unprivileged (listing needs root; verified post-boot)\n")
|
||||||
|
conflicts = netpool.overlapping_routes()
|
||||||
|
if conflicts:
|
||||||
|
detail = ", ".join(f"{c.dst} dev {c.dev}" for c in conflicts)
|
||||||
|
sys.stderr.write(f"range overlap: base {netpool.ip_base()} CLASHES "
|
||||||
|
f"with {detail}\n")
|
||||||
|
ok = False
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
|
||||||
|
_report_persistence()
|
||||||
|
if not ok:
|
||||||
|
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _report_persistence() -> None:
|
||||||
|
"""Report whether the pool is installed as the persistent systemd
|
||||||
|
unit (so it survives reboot) vs brought up imperatively. Advisory —
|
||||||
|
doesn't affect launch readiness."""
|
||||||
|
if not _has_systemd():
|
||||||
|
return
|
||||||
|
state = subprocess.run(
|
||||||
|
["systemctl", "is-active", netpool.SYSTEMD_UNIT],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
).stdout.strip() or "unknown"
|
||||||
|
if state == "active":
|
||||||
|
sys.stderr.write(f"persistence: {netpool.SYSTEMD_UNIT} active "
|
||||||
|
f"(survives reboot)\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"persistence: {netpool.SYSTEMD_UNIT} {state} — pool "
|
||||||
|
f"is not installed as a persistent unit (install with "
|
||||||
|
f"`backend setup` so it survives reboot)\n")
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Host-side primitives for the Firecracker backend.
|
||||||
|
|
||||||
|
Covers the pieces that don't need root at launch time: locating the
|
||||||
|
firecracker binary / guest kernel / injected dropbear, the fail-closed
|
||||||
|
preflight (KVM + kernel + isolation table + TAP pool must all be
|
||||||
|
present before a VM boots), the rootless rootfs pipeline
|
||||||
|
(`docker export` -> `mke2fs -d`, no mount), and per-bottle SSH key
|
||||||
|
generation.
|
||||||
|
|
||||||
|
The privileged network setup (TAP pool + nft table) is a one-time
|
||||||
|
operator step — see `netpool.py`, `scripts/firecracker-netpool.sh`,
|
||||||
|
and `./cli.py backend setup --backend=firecracker`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from . import netpool
|
||||||
|
|
||||||
|
|
||||||
|
# Guest agent images are Debian-family with USER node; the VM is
|
||||||
|
# reached over SSH as root (init drops the pubkey into both root's and
|
||||||
|
# node's authorized_keys) and commands `runuser` down to node.
|
||||||
|
GUEST_SSH_USER = "root"
|
||||||
|
|
||||||
|
# `/dev/kvm` must exist and be openable by the invoking user.
|
||||||
|
_KVM_DEVICE = "/dev/kvm"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
d = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"BOT_BOTTLE_FC_CACHE",
|
||||||
|
str(Path.home() / ".cache" / "bot-bottle" / "firecracker"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_path() -> Path:
|
||||||
|
return Path(os.environ.get("BOT_BOTTLE_FC_KERNEL", str(cache_dir() / "vmlinux")))
|
||||||
|
|
||||||
|
|
||||||
|
def dropbear_path() -> Path:
|
||||||
|
"""The static dropbear injected into every guest rootfs as its SSH
|
||||||
|
server. Must be statically linked — the guest has none of the
|
||||||
|
host's shared libraries."""
|
||||||
|
return Path(
|
||||||
|
os.environ.get("BOT_BOTTLE_FC_DROPBEAR", str(cache_dir() / "dropbear"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- availability + preflight ---------------------------------------
|
||||||
|
|
||||||
|
def is_linux() -> bool:
|
||||||
|
return platform.system() == "Linux"
|
||||||
|
|
||||||
|
|
||||||
|
def is_host_capable() -> bool:
|
||||||
|
"""Whether this host *could* run firecracker — Linux with KVM —
|
||||||
|
regardless of whether the `firecracker` binary is installed. Used
|
||||||
|
for default-backend selection so a KVM Linux host that hasn't
|
||||||
|
installed firecracker yet still selects it and gets an install
|
||||||
|
pointer at launch (see `require_firecracker`), rather than silently
|
||||||
|
falling back to docker."""
|
||||||
|
return is_linux() and os.path.exists(_KVM_DEVICE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""Cheap capability probe used by cross-backend enumeration —
|
||||||
|
firecracker on PATH, on Linux, with KVM. Does not check the
|
||||||
|
(operator-provisioned) kernel / pool, so an available-but-unset
|
||||||
|
host still shows up and gets an actionable error at launch."""
|
||||||
|
return is_host_capable() and shutil.which("firecracker") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def require_firecracker() -> None:
|
||||||
|
"""Fail-closed preflight. Every check that gates the security
|
||||||
|
boundary (the isolation table, the TAP pool) errors rather than
|
||||||
|
booting a VM without it."""
|
||||||
|
if not is_linux():
|
||||||
|
die("firecracker backend is only supported on Linux (KVM). "
|
||||||
|
"On macOS use --backend=macos-container.")
|
||||||
|
if shutil.which("firecracker") is None:
|
||||||
|
info("Firecracker is required but was not found on PATH.")
|
||||||
|
info("Install: https://github.com/firecracker-microvm/firecracker/releases")
|
||||||
|
die("firecracker not found on PATH")
|
||||||
|
_require_kvm()
|
||||||
|
if not kernel_path().is_file():
|
||||||
|
die(f"guest kernel not found at {kernel_path()}. Set "
|
||||||
|
"BOT_BOTTLE_FC_KERNEL or place a vmlinux there.")
|
||||||
|
if not dropbear_path().is_file():
|
||||||
|
die(f"static dropbear not found at {dropbear_path()}. Set "
|
||||||
|
"BOT_BOTTLE_FC_DROPBEAR or cache one there.")
|
||||||
|
if shutil.which("mke2fs") is None:
|
||||||
|
die("mke2fs (e2fsprogs) not found — required to build guest rootfs")
|
||||||
|
_require_network_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_kvm() -> None:
|
||||||
|
if not os.path.exists(_KVM_DEVICE):
|
||||||
|
die(f"{_KVM_DEVICE} is missing. Enable KVM (load kvm-intel/kvm-amd; "
|
||||||
|
"confirm virtualization is on in firmware).")
|
||||||
|
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
|
||||||
|
die(f"{_KVM_DEVICE} exists but is not accessible. Add your user to "
|
||||||
|
"the `kvm` group and re-login.")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_network_pool() -> None:
|
||||||
|
"""Fail-closed on the parts we can check unprivileged; defer the
|
||||||
|
rest to the post-boot isolation probe.
|
||||||
|
|
||||||
|
The TAP pool is verified here (`ip link show` is unprivileged). The
|
||||||
|
nft table can only be *confirmed present* here when `nft` is
|
||||||
|
queryable — which usually needs root — so a missing/absent nft is
|
||||||
|
not treated as fatal at this stage: the authoritative check is the
|
||||||
|
empirical isolation probe run after boot, before the agent starts
|
||||||
|
(see isolation_probe.verify_isolation). What we must never do is
|
||||||
|
boot without the TAP pool."""
|
||||||
|
conflicts = netpool.overlapping_routes()
|
||||||
|
if conflicts:
|
||||||
|
detail = "; ".join(f"{c.dst} dev {c.dev}" for c in conflicts)
|
||||||
|
warn(f"Firecracker pool range ({netpool.ip_base()}, "
|
||||||
|
f"{netpool.pool_size()} slots) overlaps existing routes: "
|
||||||
|
f"{detail}. This can shadow or be shadowed by that route; "
|
||||||
|
f"set BOT_BOTTLE_FC_IP_BASE to a free range and re-run "
|
||||||
|
f"./cli.py backend setup --backend=firecracker.")
|
||||||
|
missing = netpool.missing_taps()
|
||||||
|
if missing:
|
||||||
|
die(f"network pool incomplete — missing TAP devices: "
|
||||||
|
f"{', '.join(missing)}.\n ./cli.py backend setup --backend=firecracker")
|
||||||
|
if shutil.which("nft") is not None and not netpool.nft_table_present():
|
||||||
|
# nft is queryable and says the table is absent — that's a
|
||||||
|
# definite, catchable misconfiguration; fail early.
|
||||||
|
warn(f"isolation table `inet {netpool.NFT_TABLE}` not found via nft. "
|
||||||
|
"If this is a permissions issue it will be re-checked "
|
||||||
|
"empirically after boot; otherwise run: "
|
||||||
|
"./cli.py backend setup --backend=firecracker")
|
||||||
|
|
||||||
|
|
||||||
|
# --- rootfs pipeline (rootless) -------------------------------------
|
||||||
|
|
||||||
|
def docker_image_id(ref: str) -> str:
|
||||||
|
"""The image's content digest, used as the rootfs cache key."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", "--format", "{{.Id}}", ref],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
die(f"docker image inspect for {ref!r} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no output>'}")
|
||||||
|
return result.stdout.strip().replace("sha256:", "")[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def build_base_rootfs_dir(image_ref: str) -> Path:
|
||||||
|
"""Export the agent image's filesystem and inject the guest init +
|
||||||
|
static dropbear. Cached by image digest — the per-bottle bits
|
||||||
|
(authorized_keys, IP) are passed at boot via the kernel cmdline, so
|
||||||
|
this tree carries nothing bottle-specific and is safely shared.
|
||||||
|
|
||||||
|
Returns the prepared directory (read as the `mke2fs -d` source)."""
|
||||||
|
digest = docker_image_id(image_ref)
|
||||||
|
base = cache_dir() / "rootfs" / digest
|
||||||
|
ready = base / ".bb-ready"
|
||||||
|
if ready.is_file():
|
||||||
|
return base
|
||||||
|
|
||||||
|
if base.exists():
|
||||||
|
shutil.rmtree(base, ignore_errors=True)
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
|
||||||
|
info(f"exporting {image_ref} rootfs -> {base}")
|
||||||
|
cid = subprocess.run(
|
||||||
|
["docker", "create", image_ref, "sleep", "infinity"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if cid.returncode != 0 or not cid.stdout.strip():
|
||||||
|
die(f"docker create {image_ref!r} failed: {cid.stderr.strip()}")
|
||||||
|
container = cid.stdout.strip()
|
||||||
|
try:
|
||||||
|
export = subprocess.Popen(
|
||||||
|
["docker", "export", container], stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
untar = subprocess.run(
|
||||||
|
["tar", "-x", "-C", str(base)], stdin=export.stdout, check=False,
|
||||||
|
)
|
||||||
|
export.wait()
|
||||||
|
if export.returncode != 0 or untar.returncode != 0:
|
||||||
|
die(f"exporting rootfs for {image_ref!r} failed")
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_inject_guest_boot(base)
|
||||||
|
ready.write_text("ok\n")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_guest_boot(rootfs: Path) -> None:
|
||||||
|
"""Drop the static dropbear and the PID-1 init into the rootfs."""
|
||||||
|
shutil.copy2(dropbear_path(), rootfs / "bb-dropbear")
|
||||||
|
os.chmod(rootfs / "bb-dropbear", 0o755)
|
||||||
|
init = rootfs / "bb-init"
|
||||||
|
init.write_text(_GUEST_INIT)
|
||||||
|
os.chmod(init, 0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rootfs_ext4(base_dir: Path, out_path: Path, *, slack_mib: int = 1024) -> None:
|
||||||
|
"""Build a fresh, writable ext4 for one bottle from the cached base
|
||||||
|
dir. Rootless: `mke2fs -d` populates the image from a directory
|
||||||
|
without mounting. Each call produces an independent disk, so the
|
||||||
|
shared base dir stays untouched and concurrent bottles don't race."""
|
||||||
|
used_mib = _dir_size_mib(base_dir)
|
||||||
|
size_mib = used_mib + slack_mib
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
["mke2fs", "-q", "-t", "ext4", "-d", str(base_dir), "-F",
|
||||||
|
str(out_path), f"{size_mib}M"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"mke2fs for {out_path} failed: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_size_mib(path: Path) -> int:
|
||||||
|
result = subprocess.run(
|
||||||
|
["du", "-sm", str(path)], capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(result.stdout.split()[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 2048
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-bottle SSH keys --------------------------------------------
|
||||||
|
|
||||||
|
def generate_keypair(dest_dir: Path) -> tuple[Path, str]:
|
||||||
|
"""Mint a per-bottle ed25519 keypair. Returns (private_key_path,
|
||||||
|
public_key_line). The public line is injected into the guest via
|
||||||
|
the boot cmdline; the private key authenticates host->guest SSH."""
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
key = dest_dir / "bottle_id_ed25519"
|
||||||
|
if key.exists():
|
||||||
|
key.unlink()
|
||||||
|
(dest_dir / "bottle_id_ed25519.pub").unlink(missing_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", str(key),
|
||||||
|
"-C", "bot-bottle-firecracker"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
pub = (dest_dir / "bottle_id_ed25519.pub").read_text().strip()
|
||||||
|
return key, pub
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_base_argv(private_key: Path, guest_ip: str) -> list[str]:
|
||||||
|
"""Common SSH options for host->guest control. The VM is ephemeral
|
||||||
|
and per-bottle, so host-key TOFU is meaningless — pin no known_hosts
|
||||||
|
and skip the check rather than accumulate churn."""
|
||||||
|
return [
|
||||||
|
"ssh",
|
||||||
|
"-i", str(private_key),
|
||||||
|
# Only offer the per-bottle key — don't let an agent or the
|
||||||
|
# operator's ~/.ssh/config inject other identities (newer
|
||||||
|
# OpenSSH otherwise may not reliably present the -i key).
|
||||||
|
"-o", "IdentitiesOnly=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
"-o", "LogLevel=ERROR",
|
||||||
|
"-o", "ConnectTimeout=5",
|
||||||
|
f"{GUEST_SSH_USER}@{guest_ip}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# PID-1 init injected into every guest. Kept dependency-light: relies
|
||||||
|
# only on coreutils + a POSIX shell (present in the Debian-family agent
|
||||||
|
# images). The kernel `ip=` cmdline configures eth0 before init runs,
|
||||||
|
# so no iproute2 is needed. The per-bottle SSH pubkey arrives base64 on
|
||||||
|
# the cmdline; dropbear generates ephemeral host keys with -R.
|
||||||
|
_GUEST_INIT = r"""#!/bin/sh
|
||||||
|
# bot-bottle Firecracker guest init (PID 1).
|
||||||
|
mount -t proc proc /proc 2>/dev/null
|
||||||
|
mount -t sysfs sys /sys 2>/dev/null
|
||||||
|
mount -t devtmpfs dev /dev 2>/dev/null
|
||||||
|
mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null
|
||||||
|
mount -o remount,rw / 2>/dev/null
|
||||||
|
|
||||||
|
# Install the per-bottle SSH pubkey from the kernel cmdline.
|
||||||
|
KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null)
|
||||||
|
if [ -n "$KEY" ]; then
|
||||||
|
for home in /root /home/node; do
|
||||||
|
mkdir -p "$home/.ssh"
|
||||||
|
printf '%s\n' "$KEY" > "$home/.ssh/authorized_keys"
|
||||||
|
chmod 700 "$home/.ssh"
|
||||||
|
chmod 600 "$home/.ssh/authorized_keys"
|
||||||
|
done
|
||||||
|
chown -R node:node /home/node/.ssh 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The rootless rootfs build (`docker export | tar` as a non-root user)
|
||||||
|
# can't preserve uid 0, so every path lands owned by the build uid,
|
||||||
|
# which maps to `node` (uid 1000) in-guest — including /root. dropbear
|
||||||
|
# (like OpenSSH) refuses root's authorized_keys unless the home dir is
|
||||||
|
# owned by root, so restore root's ownership of its own home.
|
||||||
|
chown -R 0:0 /root 2>/dev/null || true
|
||||||
|
|
||||||
|
mkdir -p /etc/dropbear /run
|
||||||
|
# -R: generate host keys on demand. -E: log auth failures to stderr,
|
||||||
|
# captured in the host-side console.log for debugging.
|
||||||
|
/bb-dropbear -R -E -p 22 &
|
||||||
|
|
||||||
|
# Reap zombies as PID 1. dropbear is always a child, so `wait` blocks
|
||||||
|
# rather than busy-looping.
|
||||||
|
while : ; do wait ; done
|
||||||
|
"""
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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 == "firecracker":
|
||||||
|
from .firecracker.freezer import FirecrackerFreezer
|
||||||
|
return FirecrackerFreezer()
|
||||||
|
die(
|
||||||
|
f"commit is only supported for docker, macos-container, and "
|
||||||
|
f"firecracker; backend {backend_name!r} has no freezer"
|
||||||
|
)
|
||||||
|
raise AssertionError("unreachable")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""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"]
|
||||||
+38
-34
@@ -1,11 +1,4 @@
|
|||||||
"""SmolmachinesBottleBackend — the smolmachines implementation of
|
"""MacosContainerBottleBackend — Apple Container implementation."""
|
||||||
BottleBackend (PRD 0023).
|
|
||||||
|
|
||||||
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
|
||||||
the declarative provision-plan apply, supervise MCP registration)
|
|
||||||
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
|
||||||
smolmachines backend only owns the steps that are about backend
|
|
||||||
infrastructure: CA install (no-op for now), workspace, git copy-in."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,32 +11,45 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
from . import resolve_plan as _resolve_plan
|
from . import resolve_plan as _resolve_plan
|
||||||
from . import smolvm as _smolvm
|
from . import util as _container
|
||||||
from .bottle import SmolmachinesBottle
|
from .bottle import MacosContainerBottle
|
||||||
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
|
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||||
from .bottle_plan import SmolmachinesBottlePlan
|
from .bottle_plan import MacosContainerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesBottleBackend(
|
class MacosContainerBottleBackend(
|
||||||
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
|
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
|
||||||
):
|
):
|
||||||
"""smolmachines backend. Selected by
|
"""Apple Container backend. Selected by
|
||||||
`BOT_BOTTLE_BACKEND=smolmachines`."""
|
`BOT_BOTTLE_BACKEND=macos-container` or
|
||||||
|
`--backend=macos-container`."""
|
||||||
|
|
||||||
name = "smolmachines"
|
name = "macos-container"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_available(cls) -> bool:
|
def is_available(cls) -> bool:
|
||||||
"""`smolvm` on PATH. The backend additionally needs macOS
|
return _container.is_available()
|
||||||
for libkrun + TSI, but `enumerate_active` / `cleanup` are
|
|
||||||
host-shell ops that gracefully no-op on Linux too — the
|
@classmethod
|
||||||
runtime check happens at `prepare`."""
|
def setup(cls) -> int:
|
||||||
return _smolvm.is_available()
|
from . import setup as _setup
|
||||||
|
return _setup.setup()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def status(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
@@ -55,6 +61,7 @@ class SmolmachinesBottleBackend(
|
|||||||
self,
|
self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -62,9 +69,10 @@ class SmolmachinesBottleBackend(
|
|||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan | None,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
@@ -76,23 +84,19 @@ class SmolmachinesBottleBackend(
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
self, plan: SmolmachinesBottlePlan
|
self, plan: MacosContainerBottlePlan
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||||
"""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()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|
||||||
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
|
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
|
||||||
_cleanup.cleanup(plan)
|
_cleanup.cleanup(plan)
|
||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
return _enumerate.enumerate_active()
|
return _enumerate.enumerate_active()
|
||||||
|
|
||||||
|
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
||||||
|
return plan.agent_supervise_url
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""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"]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""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_agent_env_entries,
|
||||||
|
egress_resolve_token_values,
|
||||||
|
egress_sidecar_env_entries,
|
||||||
|
)
|
||||||
|
from ...git_gate import (
|
||||||
|
provision_git_gate_dynamic_keys,
|
||||||
|
revoke_git_gate_provisioned_keys,
|
||||||
|
)
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from ...supervise import DB_PATH_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)
|
||||||
|
|
||||||
|
plan = _provision_git_gate_keys(plan)
|
||||||
|
|
||||||
|
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 _provision_git_gate_keys(
|
||||||
|
plan: MacosContainerBottlePlan,
|
||||||
|
) -> MacosContainerBottlePlan:
|
||||||
|
if not plan.git_gate_plan.upstreams:
|
||||||
|
return plan
|
||||||
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
|
plan.manifest.bottle,
|
||||||
|
plan.git_gate_plan,
|
||||||
|
git_gate_state_dir(plan.slug),
|
||||||
|
)
|
||||||
|
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
||||||
|
|
||||||
|
|
||||||
|
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] = list(egress_sidecar_env_entries(plan.egress_plan))
|
||||||
|
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_DB_PATH={DB_PATH_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:
|
||||||
|
# `container run --mount type=bind` only accepts directory
|
||||||
|
# sources (a file source fails with "is not a directory") —
|
||||||
|
# mount db_path's dedicated parent dir instead of the file
|
||||||
|
# itself, same as the CA/routes mounts above.
|
||||||
|
mounts.append((
|
||||||
|
str(sp.db_path.parent),
|
||||||
|
str(Path(DB_PATH_IN_CONTAINER).parent),
|
||||||
|
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)
|
||||||
|
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||||
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
||||||
|
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
||||||
|
return ",".join(hosts)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""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:]))
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Host setup + status for the macOS Apple Container backend.
|
||||||
|
|
||||||
|
Like Docker, this backend needs no privileged network-pool provisioning
|
||||||
|
— it wants Apple's `container` CLI installed and its system service
|
||||||
|
running. `setup()` points at the install/`container system start` steps;
|
||||||
|
`status()` reports readiness. Reached via
|
||||||
|
`MacosContainerBottleBackend.setup` / `.status`, dispatched from the
|
||||||
|
generic `./cli.py backend {setup,status}`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import util as _container
|
||||||
|
|
||||||
|
|
||||||
|
def _service_running() -> bool:
|
||||||
|
if shutil.which("container") is None:
|
||||||
|
return False
|
||||||
|
return subprocess.run(
|
||||||
|
["container", "system", "status"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def setup() -> int:
|
||||||
|
if not _container.is_macos():
|
||||||
|
sys.stderr.write("macos-container backend requires macOS.\n")
|
||||||
|
return 1
|
||||||
|
if shutil.which("container") is None:
|
||||||
|
sys.stderr.write("Apple Container is required but was not found on PATH.\n")
|
||||||
|
sys.stderr.write("Install: https://github.com/apple/container/releases\n")
|
||||||
|
return 1
|
||||||
|
if not _service_running():
|
||||||
|
sys.stderr.write(
|
||||||
|
"Apple Container is installed but its system service isn't "
|
||||||
|
"running. Start it with: container system start\n"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sys.stderr.write(
|
||||||
|
"macos-container backend: ready — no privileged host setup required.\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def teardown() -> int:
|
||||||
|
sys.stderr.write(
|
||||||
|
"macos-container backend: nothing to undo — it provisions no "
|
||||||
|
"privileged host state. The Apple Container CLI and its system "
|
||||||
|
"service are left as-is (stop the service yourself with "
|
||||||
|
"`container system stop` if you want).\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> int:
|
||||||
|
ok = True
|
||||||
|
if _container.is_macos():
|
||||||
|
sys.stderr.write("host: macOS\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write("host: NOT macOS (backend unsupported here)\n")
|
||||||
|
ok = False
|
||||||
|
if shutil.which("container") is not None:
|
||||||
|
sys.stderr.write("container CLI on PATH: yes\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write("container CLI on PATH: NO\n")
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"container system service: {'running' if _service_running() else 'NOT running'}\n"
|
||||||
|
)
|
||||||
|
if not _service_running():
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
sys.stderr.write("\nRun: ./cli.py backend setup --backend=macos-container\n")
|
||||||
|
return 0 if ok else 1
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""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:
|
||||||
|
# `container build` resolves -f relative to the current working
|
||||||
|
# directory, not the build context. Anchor a relative Dockerfile to
|
||||||
|
# the context so builds work from any cwd.
|
||||||
|
if not os.path.isabs(dockerfile):
|
||||||
|
dockerfile = os.path.join(context, 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,9 +1,8 @@
|
|||||||
"""Shared print helpers for BottlePlan.print implementations.
|
"""Shared print helpers for BottlePlan.print implementations.
|
||||||
|
|
||||||
Lifts the multi-value label printer out of DockerBottlePlan so the
|
Lifts the multi-value label printer out of DockerBottlePlan so every
|
||||||
smolmachines backend (and any future backend) renders the same
|
backend (and any future backend) renders the same two-column
|
||||||
two-column scannable preflight without duplicating the indent
|
scannable preflight without duplicating the indent math."""
|
||||||
math."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Shared helpers used by both backends' resolve_plan steps.
|
"""Shared helpers used across backends' resolve_plan steps.
|
||||||
|
|
||||||
Each helper owns one well-defined step of the per-bottle plan
|
Each helper owns one well-defined step of the per-bottle plan
|
||||||
resolution so docker and smolmachines don't repeat the same logic.
|
resolution so the backends don't repeat the same logic.
|
||||||
Backend-specific steps (container names, env-file, per-bottle
|
Backend-specific steps (container names, env-file, per-bottle
|
||||||
Dockerfile overrides, subnet allocation) stay in the backend's own
|
Dockerfile overrides, subnet allocation) stay in the backend's own
|
||||||
resolve_plan.py.
|
resolve_plan.py.
|
||||||
@@ -26,15 +26,25 @@ from ..bottle_state import (
|
|||||||
)
|
)
|
||||||
from ..egress import Egress, EgressPlan
|
from ..egress import Egress, EgressPlan
|
||||||
from ..git_gate import GitGate, GitGatePlan
|
from ..git_gate import GitGate, GitGatePlan
|
||||||
from ..manifest import ManifestBottle
|
from ..manifest import Manifest, ManifestBottle
|
||||||
from ..supervise import Supervise, SupervisePlan
|
from ..supervise import Supervise, SupervisePlan
|
||||||
from . import BottleSpec
|
from . import BottleSpec
|
||||||
|
|
||||||
|
|
||||||
def mint_slug(spec: BottleSpec) -> str:
|
def mint_slug(spec: BottleSpec) -> str:
|
||||||
"""Return the bottle identity: the recorded identity for a resume,
|
"""Return the bottle identity: the recorded identity for a resume,
|
||||||
or a freshly minted one for a new start."""
|
or a freshly minted one for a new start.
|
||||||
return spec.identity or bottle_identity(spec.agent_name)
|
|
||||||
|
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(
|
def write_launch_metadata(
|
||||||
@@ -53,14 +63,14 @@ def write_launch_metadata(
|
|||||||
backend=backend,
|
backend=backend,
|
||||||
label=spec.label,
|
label=spec.label,
|
||||||
color=spec.color,
|
color=spec.color,
|
||||||
|
bottle_names=spec.bottle_names,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
|
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
||||||
"""Create the agent state subdir, write the prompt file.
|
"""Create the agent state subdir, write the prompt file.
|
||||||
Returns (agent_dir, prompt_file)."""
|
Returns (agent_dir, prompt_file)."""
|
||||||
manifest = spec.manifest
|
agent = manifest.agent
|
||||||
agent = manifest.agents[spec.agent_name]
|
|
||||||
agent_dir = agent_state_dir(slug)
|
agent_dir = agent_state_dir(slug)
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prompt_file = agent_dir / "prompt.txt"
|
prompt_file = agent_dir / "prompt.txt"
|
||||||
|
|||||||
@@ -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,183 +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
|
|
||||||
from typing import Mapping, cast
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
|
||||||
from .. import Bottle, ExecResult
|
|
||||||
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",
|
|
||||||
) -> 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.agent_provider_template = (
|
|
||||||
"codex" if agent_command == "codex" else "claude"
|
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
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."""
|
|
||||||
return subprocess.run(
|
|
||||||
self.agent_argv(argv, tty=tty), 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,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,428 +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 warn
|
|
||||||
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
agent_from_path = _ensure_smolmachine(
|
|
||||||
plan.agent_image,
|
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
_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,
|
|
||||||
)
|
|
||||||
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.spec.manifest.bottle_for(plan.spec.agent_name)
|
|
||||||
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
|
|
||||||
def _allocate_resources(
|
|
||||||
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")
|
|
||||||
guest_env = {
|
|
||||||
**plan.guest_env,
|
|
||||||
"HTTPS_PROXY": agent_proxy_url,
|
|
||||||
"HTTP_PROXY": agent_proxy_url,
|
|
||||||
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
|
|
||||||
}
|
|
||||||
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).
|
|
||||||
|
|
||||||
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",
|
|
||||||
"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), EGRESS_ROUTES_IN_CONTAINER, 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 _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,80 +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 ...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,
|
|
||||||
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,
|
|
||||||
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,247 +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 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))
|
|
||||||
|
|
||||||
|
|
||||||
# --- 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_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
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""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)
|
||||||
+54
-22
@@ -1,8 +1,7 @@
|
|||||||
"""Per-bottle persistent state (PRD 0016).
|
"""Per-bottle persistent state.
|
||||||
|
|
||||||
Holds the per-bottle Dockerfile override that capability-block
|
Holds optional per-bottle Dockerfile overrides, the transcript snapshot
|
||||||
remediation writes, the transcript snapshot the state-preservation
|
the state-preservation helper saves before teardown, and the launch metadata that lets
|
||||||
helper saves before teardown, and the launch metadata that lets
|
|
||||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||||
lives at:
|
lives at:
|
||||||
|
|
||||||
@@ -43,6 +42,7 @@ from . import supervise as _supervise
|
|||||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||||
_STATE_SUBDIR = "state"
|
_STATE_SUBDIR = "state"
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
|
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
# 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
|
# live here so chunk 3's `docker compose up` can find them at stable
|
||||||
@@ -60,7 +60,7 @@ _METADATA_NAME = "metadata.json"
|
|||||||
_LIVE_CONFIG_SUBDIR = "live-config"
|
_LIVE_CONFIG_SUBDIR = "live-config"
|
||||||
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
||||||
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
||||||
# Empty marker file. capability_apply writes it before teardown so
|
# Empty marker file. Session preservation writes it before teardown so
|
||||||
# cli.py's session-end cleanup knows to preserve the state dir for
|
# cli.py's session-end cleanup knows to preserve the state dir for
|
||||||
# `cli.py resume <identity>`. Absent = clean up.
|
# `cli.py resume <identity>`. Absent = clean up.
|
||||||
_PRESERVE_MARKER = ".preserve"
|
_PRESERVE_MARKER = ".preserve"
|
||||||
@@ -105,12 +105,16 @@ class BottleMetadata:
|
|||||||
# written before chunk 3 (resume / inspect should fall back to
|
# written before chunk 3 (resume / inspect should fall back to
|
||||||
# deriving from identity in that case).
|
# deriving from identity in that case).
|
||||||
compose_project: str = ""
|
compose_project: str = ""
|
||||||
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
|
# PRD 0040: backend name ("docker", "firecracker", "macos-container").
|
||||||
# for state dirs written before PRD 0040; callers default to "docker"
|
# Empty string for state dirs written before PRD 0040; callers default
|
||||||
# for backward compatibility.
|
# to "docker" for backward compatibility.
|
||||||
backend: str = ""
|
backend: str = ""
|
||||||
label: str = ""
|
label: str = ""
|
||||||
color: str = ""
|
color: str = ""
|
||||||
|
# Ordered bottle names selected at launch (issue #269). Empty tuple
|
||||||
|
# for state dirs written before this change; resume falls back to
|
||||||
|
# the agent's `bottle:` field in that case.
|
||||||
|
bottle_names: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def metadata_path(identity: str) -> Path:
|
def metadata_path(identity: str) -> Path:
|
||||||
@@ -138,6 +142,10 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
|||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
raw_typed = cast(dict[str, object], raw)
|
raw_typed = cast(dict[str, object], raw)
|
||||||
|
raw_bottle_names = raw_typed.get("bottle_names", [])
|
||||||
|
bottle_names: tuple[str, ...] = ()
|
||||||
|
if isinstance(raw_bottle_names, list):
|
||||||
|
bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str))
|
||||||
return BottleMetadata(
|
return BottleMetadata(
|
||||||
identity=str(raw_typed.get("identity", identity)),
|
identity=str(raw_typed.get("identity", identity)),
|
||||||
agent_name=str(raw_typed.get("agent_name", "")),
|
agent_name=str(raw_typed.get("agent_name", "")),
|
||||||
@@ -148,6 +156,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
|||||||
backend=str(raw_typed.get("backend", "")),
|
backend=str(raw_typed.get("backend", "")),
|
||||||
label=str(raw_typed.get("label", "")),
|
label=str(raw_typed.get("label", "")),
|
||||||
color=str(raw_typed.get("color", "")),
|
color=str(raw_typed.get("color", "")),
|
||||||
|
bottle_names=bottle_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,8 +172,7 @@ def per_bottle_dockerfile_path(identity: str) -> Path:
|
|||||||
|
|
||||||
def per_bottle_dockerfile(identity: str) -> str | None:
|
def per_bottle_dockerfile(identity: str) -> str | None:
|
||||||
"""Return the per-bottle Dockerfile content if present, else
|
"""Return the per-bottle Dockerfile content if present, else
|
||||||
None. None means: use the repo's Dockerfile (the original
|
None. None means: use the provider or manifest Dockerfile."""
|
||||||
pre-capability-block behavior)."""
|
|
||||||
p = per_bottle_dockerfile_path(identity)
|
p = per_bottle_dockerfile_path(identity)
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
return p.read_text()
|
return p.read_text()
|
||||||
@@ -179,6 +187,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
|||||||
return p
|
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:
|
def per_bottle_image_tag(identity: str) -> str:
|
||||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||||
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
||||||
@@ -222,9 +256,7 @@ def write_live_config(
|
|||||||
|
|
||||||
|
|
||||||
def transcript_snapshot_dir(identity: str) -> Path:
|
def transcript_snapshot_dir(identity: str) -> Path:
|
||||||
"""Where capability_apply stashes the agent's transcript before
|
"""Where agent session snapshots are kept for resume flows."""
|
||||||
teardown, so the next `cli.py start <agent>` can offer to
|
|
||||||
resume from it."""
|
|
||||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
@@ -251,11 +283,9 @@ def git_gate_state_dir(identity: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
"""State subdir for the supervise sidecar's current-config dir
|
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||||
(bind-mounted into the agent at /etc/bot-bottle/current-config).
|
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
||||||
The queue dir is intentionally NOT under here — it lives at
|
database, so they survive state-dir cleanup."""
|
||||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
|
||||||
survives state-dir cleanup."""
|
|
||||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
@@ -274,9 +304,8 @@ def preserve_marker_path(identity: str) -> Path:
|
|||||||
|
|
||||||
def mark_preserved(identity: str) -> Path:
|
def mark_preserved(identity: str) -> Path:
|
||||||
"""Mark this bottle's state for preservation across session
|
"""Mark this bottle's state for preservation across session
|
||||||
teardown. Written by capability_apply.apply_capability_change so
|
teardown so cli.py's session-end cleanup leaves the state dir
|
||||||
cli.py's session-end cleanup leaves the state dir intact for a
|
intact for a subsequent `cli.py resume`."""
|
||||||
subsequent `cli.py resume`."""
|
|
||||||
path = preserve_marker_path(identity)
|
path = preserve_marker_path(identity)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.touch()
|
path.touch()
|
||||||
@@ -289,7 +318,7 @@ def is_preserved(identity: str) -> bool:
|
|||||||
|
|
||||||
def clear_preserve_marker(identity: str) -> None:
|
def clear_preserve_marker(identity: str) -> None:
|
||||||
"""Idempotent removal. Called at fresh launch (start or resume)
|
"""Idempotent removal. Called at fresh launch (start or resume)
|
||||||
so a marker left from a prior capability-block doesn't keep
|
so a marker left from a prior preserved session doesn't keep
|
||||||
state alive past the next normal session-end."""
|
state alive past the next normal session-end."""
|
||||||
try:
|
try:
|
||||||
preserve_marker_path(identity).unlink()
|
preserve_marker_path(identity).unlink()
|
||||||
@@ -314,6 +343,7 @@ __all__ = [
|
|||||||
"bottle_state_dir",
|
"bottle_state_dir",
|
||||||
"cleanup_state",
|
"cleanup_state",
|
||||||
"clear_preserve_marker",
|
"clear_preserve_marker",
|
||||||
|
"committed_image_path",
|
||||||
"egress_state_dir",
|
"egress_state_dir",
|
||||||
"git_gate_state_dir",
|
"git_gate_state_dir",
|
||||||
"is_preserved",
|
"is_preserved",
|
||||||
@@ -323,9 +353,11 @@ __all__ = [
|
|||||||
"per_bottle_dockerfile_path",
|
"per_bottle_dockerfile_path",
|
||||||
"per_bottle_image_tag",
|
"per_bottle_image_tag",
|
||||||
"preserve_marker_path",
|
"preserve_marker_path",
|
||||||
|
"read_committed_image",
|
||||||
"read_metadata",
|
"read_metadata",
|
||||||
"supervise_state_dir",
|
"supervise_state_dir",
|
||||||
"transcript_snapshot_dir",
|
"transcript_snapshot_dir",
|
||||||
|
"write_committed_image",
|
||||||
"write_metadata",
|
"write_metadata",
|
||||||
"write_per_bottle_dockerfile",
|
"write_per_bottle_dockerfile",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
"""Main CLI dispatcher.
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
Commands: backend, cleanup, commit, edit, info, init, list, resume, start, supervise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from ..errors import MissingEnvVarError
|
||||||
from ..log import Die, die, error
|
from ..log import Die, die, error
|
||||||
from ..manifest import ManifestError
|
from ..manifest import ManifestError
|
||||||
|
from ..store_manager import StoreManager
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
|
from .backend import cmd_backend
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
|
from .commit import cmd_commit
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
@@ -22,7 +26,9 @@ from .supervise import cmd_supervise
|
|||||||
cmd_list = _list_mod.cmd_list
|
cmd_list = _list_mod.cmd_list
|
||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
|
"backend": cmd_backend,
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
|
"commit": cmd_commit,
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
@@ -36,7 +42,9 @@ COMMANDS = {
|
|||||||
def usage() -> None:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
|
sys.stderr.write(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
|
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
@@ -71,11 +79,25 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if handler is None:
|
if handler is None:
|
||||||
usage()
|
usage()
|
||||||
die(f"unknown command: {command}")
|
die(f"unknown command: {command}")
|
||||||
|
mgr = StoreManager.instance()
|
||||||
|
if not mgr.is_migrated():
|
||||||
|
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
||||||
|
sys.stderr.write("Migrate now? [y/N] ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
try:
|
||||||
|
answer = sys.stdin.readline().strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
answer = ""
|
||||||
|
if answer != "y":
|
||||||
|
error("migration required — re-run and confirm to migrate")
|
||||||
|
return 1
|
||||||
|
mgr.migrate()
|
||||||
try:
|
try:
|
||||||
return handler(rest) or 0
|
return handler(rest) or 0
|
||||||
|
except MissingEnvVarError as e:
|
||||||
|
error(str(e))
|
||||||
|
return 1
|
||||||
except ManifestError as e:
|
except ManifestError as e:
|
||||||
# Manifest/config problems surface as a catchable exception;
|
|
||||||
# print the reason and exit non-zero (same UX die() used to give).
|
|
||||||
error(str(e))
|
error(str(e))
|
||||||
return 1
|
return 1
|
||||||
except Die as e:
|
except Die as e:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""`backend` CLI command — generic host setup/status across backends.
|
||||||
|
|
||||||
|
`./cli.py backend setup [--backend=NAME]` provisions (or points at how
|
||||||
|
to provision) the chosen backend's one-time host prerequisites.
|
||||||
|
`./cli.py backend status [--backend=NAME]` reports readiness.
|
||||||
|
`./cli.py backend teardown [--backend=NAME]` undoes setup (uninstall).
|
||||||
|
|
||||||
|
All dispatch to the backend's `setup()` / `status()` / `teardown()`
|
||||||
|
classmethods, so there are no backend-specific commands — swapping
|
||||||
|
backends is just a different `--backend` (or `$BOT_BOTTLE_BACKEND`, or
|
||||||
|
the host default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from ..backend import get_bottle_backend, known_backend_names
|
||||||
|
from ._common import PROG
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_backend(args: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=f"{PROG} backend",
|
||||||
|
description="Set up or check a backend's host prerequisites.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"action",
|
||||||
|
choices=("setup", "status", "teardown"),
|
||||||
|
help="setup: provision/print host prerequisites; status: report "
|
||||||
|
"readiness; teardown: undo setup (uninstall)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend",
|
||||||
|
choices=known_backend_names(),
|
||||||
|
default=None,
|
||||||
|
help="backend to target (default: $BOT_BOTTLE_BACKEND or the host default)",
|
||||||
|
)
|
||||||
|
ns = parser.parse_args(args)
|
||||||
|
|
||||||
|
backend = get_bottle_backend(ns.backend)
|
||||||
|
if ns.action == "setup":
|
||||||
|
return backend.setup()
|
||||||
|
if ns.action == "teardown":
|
||||||
|
return backend.teardown()
|
||||||
|
return backend.status()
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
"""cleanup: stop and remove all orphaned bot-bottle resources.
|
||||||
|
|
||||||
Walks every registered backend (docker + smolmachines) so a single
|
Walks every registered backend (docker, firecracker, macos-container)
|
||||||
`./cli.py cleanup` reaps both backends' leftovers — orphaned
|
so a single `./cli.py cleanup` reaps every backend's leftovers — a
|
||||||
smolvm machines won't survive a docker-only cleanup pass (issue
|
firecracker bottle's sidecars won't survive a docker-only cleanup pass
|
||||||
addressed alongside #77).
|
(issue addressed alongside #77).
|
||||||
|
|
||||||
Each backend's `prepare_cleanup` enumerates its own resources;
|
Each backend's `prepare_cleanup` enumerates its own resources;
|
||||||
docker's `_list_orphan_state_dirs` consults
|
docker's `_list_orphan_state_dirs` consults
|
||||||
`enumerate_active_agents()` for the union of live identities so
|
`enumerate_active_agents()` for the union of live identities so
|
||||||
state dirs of running smolmachines bottles aren't reaped. State
|
state dirs of running non-docker bottles aren't reaped. State
|
||||||
dirs are shared layout, so docker is the single owner of that
|
dirs are shared layout, so docker is the single owner of that
|
||||||
bucket.
|
bucket.
|
||||||
|
|
||||||
State dirs with `.preserve` are intentionally never touched — they
|
State dirs with `.preserve` are intentionally never touched — they
|
||||||
hold capability-block rebuilds or crash snapshots the operator may
|
hold preserved sessions the operator may want to `resume`. Manual
|
||||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
`rm -rf ~/.bot-bottle/state/<identity>` is the path for those.
|
||||||
is the path for those.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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.
|
||||||
|
Firecracker bottles stream the guest rootfs out over SSH and rebuild a
|
||||||
|
local Docker image. 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
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|
||||||
|
|
||||||
@@ -14,11 +14,12 @@ def cmd_info(argv: list[str]) -> int:
|
|||||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
names = ManifestIndex.resolve(USER_CWD)
|
||||||
manifest.require_agent(args.name)
|
names.require_agent(args.name)
|
||||||
|
manifest = names.load_for_agent(args.name)
|
||||||
|
|
||||||
agent = manifest.agents[args.name]
|
agent = manifest.agent
|
||||||
bottle = manifest.bottle_for(args.name)
|
bottle = manifest.bottle
|
||||||
env_names = list(bottle.env.keys())
|
env_names = list(bottle.env.keys())
|
||||||
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ def cmd_info(argv: list[str]) -> int:
|
|||||||
f"first line: {prompt_first_line or '(empty)'}"
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
)
|
)
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
identity = manifest.git_identity_summary(args.name)
|
identity = manifest.git_identity_summary()
|
||||||
if identity:
|
if identity:
|
||||||
info(f" git identity : {identity}")
|
info(f" git identity : {identity}")
|
||||||
if bottle.git:
|
if bottle.git:
|
||||||
|
|||||||
+12
-22
@@ -7,26 +7,15 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
from ..backend import enumerate_active_agents
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|
||||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
_ANSI_COLOR_CODES: dict[str, str] = {
|
||||||
"black": "\033[30m",
|
"red": "\033[91m",
|
||||||
"red": "\033[31m",
|
"green": "\033[92m",
|
||||||
"green": "\033[32m",
|
"yellow": "\033[93m",
|
||||||
"yellow": "\033[33m",
|
"blue": "\033[94m",
|
||||||
"blue": "\033[34m",
|
"magenta": "\033[95m",
|
||||||
"magenta": "\033[35m",
|
|
||||||
"cyan": "\033[36m",
|
|
||||||
"white": "\033[37m",
|
|
||||||
"bright-black": "\033[90m",
|
|
||||||
"bright-red": "\033[91m",
|
|
||||||
"bright-green": "\033[92m",
|
|
||||||
"bright-yellow": "\033[93m",
|
|
||||||
"bright-blue": "\033[94m",
|
|
||||||
"bright-magenta": "\033[95m",
|
|
||||||
"bright-cyan": "\033[96m",
|
|
||||||
"bright-white": "\033[97m",
|
|
||||||
}
|
}
|
||||||
_ANSI_RESET = "\033[0m"
|
_ANSI_RESET = "\033[0m"
|
||||||
|
|
||||||
@@ -51,13 +40,14 @@ def cmd_list(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.scope == "available":
|
if args.scope == "available":
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
for name in manifest.agents.keys():
|
for name in manifest.all_agent_names:
|
||||||
print(name)
|
print(name)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# `active` enumerates every backend (docker + smolmachines)
|
# `active` enumerates every backend (docker, firecracker,
|
||||||
# so smolmachines bottles aren't hidden behind the env var.
|
# macos-container) so non-docker bottles aren't hidden behind
|
||||||
|
# the env var.
|
||||||
active = enumerate_active_agents()
|
active = enumerate_active_agents()
|
||||||
if not active:
|
if not active:
|
||||||
print("no active bot-bottle bottles", file=sys.stderr)
|
print("no active bot-bottle bottles", file=sys.stderr)
|
||||||
@@ -66,7 +56,7 @@ def cmd_list(argv: list[str]) -> int:
|
|||||||
# Tab-separated keeps the format stable for shell pipelines.
|
# Tab-separated keeps the format stable for shell pipelines.
|
||||||
for b in active:
|
for b in active:
|
||||||
services = ",".join(b.services) if b.services else "-"
|
services = ",".join(b.services) if b.services else "-"
|
||||||
display_name = b.label if b.label else b.agent_name
|
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name
|
||||||
colored_name = _ansi_label(display_name, b.color)
|
colored_name = _ansi_label(display_name, b.color)
|
||||||
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
|
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
|||||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
||||||
then runs the same launch core as `start` — but pinned to the
|
then runs the same launch core as `start` — but pinned to the
|
||||||
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
||||||
(from capability-block apply) and transcript snapshot under the same
|
override and transcript snapshot under the same state dir.
|
||||||
state dir.
|
|
||||||
|
|
||||||
Use case: an agent calls capability-block, the dashboard approves
|
Use case: an interrupted or preserved bottle needs to be relaunched;
|
||||||
and tears down the bottle, the operator runs
|
the operator runs
|
||||||
./cli.py resume <identity>
|
./cli.py resume <identity>
|
||||||
to bring up the replacement with the new capabilities baked in.
|
to bring up the replacement from the recorded state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,7 +19,7 @@ import argparse
|
|||||||
from ..backend import BottleSpec
|
from ..backend import BottleSpec
|
||||||
from ..bottle_state import read_metadata
|
from ..bottle_state import read_metadata
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
from .start import _launch_bottle
|
from .start import _launch_bottle
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ from .start import _launch_bottle
|
|||||||
def cmd_resume(argv: list[str]) -> int:
|
def cmd_resume(argv: list[str]) -> int:
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--remote-control", action="store_true")
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"identity",
|
"identity",
|
||||||
help="bottle identity from a prior `start` (see its session-end output)",
|
help="bottle identity from a prior `start` (see its session-end output)",
|
||||||
@@ -42,7 +40,7 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
manifest.require_agent(metadata.agent_name)
|
manifest.require_agent(metadata.agent_name)
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
@@ -51,11 +49,11 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
copy_cwd=metadata.copy_cwd,
|
copy_cwd=metadata.copy_cwd,
|
||||||
user_cwd=metadata.cwd or USER_CWD,
|
user_cwd=metadata.cwd or USER_CWD,
|
||||||
identity=metadata.identity,
|
identity=metadata.identity,
|
||||||
|
bottle_names=tuple(metadata.bottle_names),
|
||||||
)
|
)
|
||||||
backend_name = metadata.backend or None
|
backend_name = metadata.backend or None
|
||||||
return _launch_bottle(
|
return _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
remote_control=args.remote_control,
|
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
|||||||
+337
-31
@@ -2,6 +2,11 @@
|
|||||||
interactive claude-code session. The container is torn down when the
|
interactive claude-code session. The container is torn down when the
|
||||||
session ends.
|
session ends.
|
||||||
|
|
||||||
|
`--headless` selects a non-interactive launch (agent/bottles/label from
|
||||||
|
flags, no TUI selectors, no y/N prompt) for orchestrators,
|
||||||
|
CI, and webhook dispatch. The agent still execs on the inherited
|
||||||
|
stdio/PTY, so an orchestrator that allocates the PTY drives the session.
|
||||||
|
|
||||||
The launch core is shared with `cli.py resume <identity>` through
|
The launch core is shared with `cli.py resume <identity>` through
|
||||||
the private orchestrator `_launch_bottle`.
|
the private orchestrator `_launch_bottle`.
|
||||||
"""
|
"""
|
||||||
@@ -16,22 +21,23 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from ..agent_provider import runtime_for
|
from ..agent_provider import get_provider, runtime_for
|
||||||
from ..backend import (
|
from ..backend import (
|
||||||
Bottle,
|
Bottle,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
|
enumerate_active_agents,
|
||||||
get_bottle_backend,
|
get_bottle_backend,
|
||||||
known_backend_names,
|
known_backend_names,
|
||||||
)
|
)
|
||||||
|
from ..backend.docker import util as docker_mod
|
||||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from ..bottle_state import (
|
from ..bottle_state import (
|
||||||
cleanup_state,
|
cleanup_state,
|
||||||
is_preserved,
|
is_preserved,
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
)
|
)
|
||||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
from ..log import info, die
|
||||||
from ..log import info
|
from ..manifest import Manifest, ManifestIndex
|
||||||
from ..manifest import Manifest
|
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
from . import tui
|
from . import tui
|
||||||
|
|
||||||
@@ -40,16 +46,48 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||||
parser.add_argument("--dry-run", action="store_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("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||||
parser.add_argument("--remote-control", action="store_true")
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
choices=known_backend_names(),
|
choices=known_backend_names(),
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
|
||||||
"or 'smolmachines'). Overrides the env var when set."
|
"or host auto-selection). Overrides the env var when set."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--headless",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"non-interactive launch: take agent/bottles/label from flags, "
|
||||||
|
"skip all prompts. For orchestrators, CI, and webhooks."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bottle",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
help=(
|
||||||
|
"bottle to compose, repeatable (order = merge order). In "
|
||||||
|
"--headless, defaults to the agent's own bottle when omitted."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--label",
|
||||||
|
default=None,
|
||||||
|
help="bottle label / terminal title (--headless default: agent name)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--color",
|
||||||
|
default=None,
|
||||||
|
help="bottle color, one of the 16 ANSI color names (--headless default: none)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt",
|
||||||
|
default=None,
|
||||||
|
help="initial task prompt delivered to the agent (required with --headless)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"name",
|
"name",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
@@ -60,20 +98,49 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
|
backend_name: str | None = args.backend
|
||||||
|
|
||||||
|
if args.headless:
|
||||||
|
return _start_headless(
|
||||||
|
manifest, args, dry_run=dry_run, backend_name=backend_name
|
||||||
|
)
|
||||||
|
|
||||||
agent_name: str | None = args.name
|
agent_name: str | None = args.name
|
||||||
if agent_name is None:
|
if agent_name is None:
|
||||||
|
if not manifest.all_agent_names:
|
||||||
|
print(
|
||||||
|
"bot-bottle: no agents defined. "
|
||||||
|
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
agent_name = tui.filter_select(
|
agent_name = tui.filter_select(
|
||||||
sorted(manifest.agents.keys()),
|
manifest.all_agent_names,
|
||||||
title="Select agent",
|
title="Select agent",
|
||||||
)
|
)
|
||||||
if agent_name is None:
|
if agent_name is None:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
backend_name: str | None = args.backend
|
# Bottle multiselect: always show after agent selection so operators
|
||||||
|
# can compose bottles at launch time without editing agent manifests.
|
||||||
|
available_bottles = manifest.all_bottle_names
|
||||||
|
lineage_map = _bottle_lineage(manifest)
|
||||||
|
display_labels = [lineage_map.get(n, n) for n in available_bottles]
|
||||||
|
label_to_name = {lineage_map.get(n, n): n for n in available_bottles}
|
||||||
|
initial_bottle = _peek_agent_bottle(manifest, agent_name)
|
||||||
|
initial_labels = [lineage_map.get(initial_bottle, initial_bottle)] if initial_bottle else []
|
||||||
|
selected_labels = tui.filter_multiselect(
|
||||||
|
display_labels,
|
||||||
|
title="Select bottles",
|
||||||
|
initial=initial_labels,
|
||||||
|
)
|
||||||
|
if selected_labels is None:
|
||||||
|
return 0
|
||||||
|
bottle_names = tuple(label_to_name.get(lbl, lbl) for lbl in selected_labels)
|
||||||
|
|
||||||
label, color = tui.name_color_modal(default_label=agent_name)
|
label, color = tui.name_color_modal(default_label=agent_name)
|
||||||
|
label, color = _resolve_unique_label(label, color)
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -82,15 +149,93 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
user_cwd=USER_CWD,
|
user_cwd=USER_CWD,
|
||||||
label=label,
|
label=label,
|
||||||
color=color,
|
color=color,
|
||||||
|
bottle_names=bottle_names,
|
||||||
)
|
)
|
||||||
return _launch_bottle(
|
return _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
remote_control=args.remote_control,
|
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Headless launch -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _start_headless(
|
||||||
|
manifest: ManifestIndex,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
backend_name: str | None,
|
||||||
|
) -> int:
|
||||||
|
"""Non-interactive launch path for orchestrators / CI / webhooks.
|
||||||
|
|
||||||
|
Resolves agent, bottles, label, and color from flags + manifest
|
||||||
|
defaults instead of the TUI selectors, and auto-confirms the
|
||||||
|
preflight. Otherwise runs the same launch core as the interactive
|
||||||
|
path, so the agent still execs on the inherited stdio/PTY — an
|
||||||
|
orchestrator allocates that PTY and relays it to its
|
||||||
|
desktop/mobile clients."""
|
||||||
|
agent_name = args.name
|
||||||
|
if not agent_name:
|
||||||
|
die("--headless requires an agent name: ./cli.py start <agent> --headless")
|
||||||
|
manifest.require_agent(agent_name) # raises ManifestError if unknown
|
||||||
|
|
||||||
|
prompt = args.prompt
|
||||||
|
if not prompt:
|
||||||
|
die(
|
||||||
|
"--headless requires --prompt: "
|
||||||
|
"./cli.py start <agent> --headless --prompt 'Do the thing'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.bottle:
|
||||||
|
bottle_names: tuple[str, ...] = tuple(args.bottle)
|
||||||
|
else:
|
||||||
|
default_bottle = _peek_agent_bottle(manifest, agent_name)
|
||||||
|
if not default_bottle:
|
||||||
|
die(
|
||||||
|
f"--headless: agent '{agent_name}' has no default bottle; "
|
||||||
|
f"pass one or more --bottle NAME"
|
||||||
|
)
|
||||||
|
bottle_names = (default_bottle,)
|
||||||
|
|
||||||
|
label = _uniquify_label_headless(args.label or agent_name)
|
||||||
|
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=manifest,
|
||||||
|
agent_name=agent_name,
|
||||||
|
copy_cwd=args.cwd,
|
||||||
|
user_cwd=USER_CWD,
|
||||||
|
label=label,
|
||||||
|
color=args.color or "",
|
||||||
|
bottle_names=bottle_names,
|
||||||
|
headless=True,
|
||||||
|
)
|
||||||
|
return _launch_bottle(
|
||||||
|
spec,
|
||||||
|
dry_run=dry_run,
|
||||||
|
backend_name=backend_name,
|
||||||
|
assume_yes=True,
|
||||||
|
headless_prompt_text=prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _uniquify_label_headless(label: str) -> str:
|
||||||
|
"""Non-interactive analog of `_resolve_unique_label`: if the label's
|
||||||
|
slug collides with a running bottle, append -2, -3, … until free,
|
||||||
|
logging the chosen label. Orchestrators fire-and-forget many bottles,
|
||||||
|
so silently picking a free name beats erroring on every collision."""
|
||||||
|
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||||
|
if docker_mod.slugify(label) not in active_slugs:
|
||||||
|
return label
|
||||||
|
n = 2
|
||||||
|
while docker_mod.slugify(f"{label}-{n}") in active_slugs:
|
||||||
|
n += 1
|
||||||
|
chosen = f"{label}-{n}"
|
||||||
|
info(f"label '{label}' already in use; using '{chosen}'")
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
# --- Launch helpers ------------------------------------------------------
|
# --- Launch helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +243,7 @@ def prepare_with_preflight(
|
|||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
render_preflight: Callable[[DockerBottlePlan], None],
|
render_preflight: Callable[[DockerBottlePlan, str], None],
|
||||||
prompt_yes: Callable[[], bool],
|
prompt_yes: Callable[[], bool],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
@@ -107,8 +252,8 @@ def prepare_with_preflight(
|
|||||||
injected callable, prompt y/N via the injected callable.
|
injected callable, prompt y/N via the injected callable.
|
||||||
|
|
||||||
`backend_name` selects which backend prepares the plan
|
`backend_name` selects which backend prepares the plan
|
||||||
(`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes
|
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
|
||||||
whatever `--backend` resolved to.
|
passes whatever `--backend` resolved to.
|
||||||
|
|
||||||
Returns `(plan, identity)`. `plan` is None on dry-run or
|
Returns `(plan, identity)`. `plan` is None on dry-run or
|
||||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
operator-N, but `identity` is set as soon as `backend.prepare`
|
||||||
@@ -119,7 +264,7 @@ def prepare_with_preflight(
|
|||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
identity = _identity_from_plan(plan)
|
identity = _identity_from_plan(plan)
|
||||||
|
|
||||||
render_preflight(plan)
|
render_preflight(plan, backend.name)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
info("dry-run requested; not starting container.")
|
info("dry-run requested; not starting container.")
|
||||||
@@ -131,8 +276,9 @@ def prepare_with_preflight(
|
|||||||
|
|
||||||
|
|
||||||
def attach_agent(
|
def attach_agent(
|
||||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
bottle: Bottle, *, resume: bool = False,
|
||||||
agent_provider_template: str = "claude",
|
agent_provider_template: str = "claude",
|
||||||
|
startup_args: tuple[str, ...] = (),
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the selected provider CLI inside `bottle` as an
|
"""Run the selected provider CLI inside `bottle` as an
|
||||||
interactive session. Blocks until the session ends; returns the
|
interactive session. Blocks until the session ends; returns the
|
||||||
@@ -149,8 +295,7 @@ def attach_agent(
|
|||||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||||
)
|
)
|
||||||
agent_args = list(runtime.bypass_args)
|
agent_args = list(runtime.bypass_args)
|
||||||
if remote_control:
|
agent_args.extend(startup_args)
|
||||||
agent_args.extend(runtime.remote_control_args)
|
|
||||||
if resume:
|
if resume:
|
||||||
agent_args.extend(runtime.resume_args)
|
agent_args.extend(runtime.resume_args)
|
||||||
return bottle.exec_agent(agent_args, tty=True)
|
return bottle.exec_agent(agent_args, tty=True)
|
||||||
@@ -189,6 +334,53 @@ def _identity_from_plan(plan: object) -> str:
|
|||||||
return getattr(plan, "slug", "")
|
return getattr(plan, "slug", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
|
||||||
|
"""Return the `bottle:` value from the named agent's frontmatter without
|
||||||
|
fully parsing the agent file, or "" when absent or unreadable.
|
||||||
|
|
||||||
|
Used to pre-populate the bottle multiselect with the agent's default
|
||||||
|
bottle so operators who haven't removed `bottle:` from their manifests
|
||||||
|
don't need to re-select it every time."""
|
||||||
|
if manifest.home_md is None:
|
||||||
|
# Eager mode (from_json_obj): agent is pre-parsed.
|
||||||
|
if agent_name in manifest.agents:
|
||||||
|
return manifest.agents[agent_name].bottle
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from ..manifest_loader import scan_agent_names
|
||||||
|
from ..yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
|
home_agents = scan_agent_names(manifest.home_md / "agents")
|
||||||
|
cwd_agents: dict[str, Path] = {}
|
||||||
|
if manifest.cwd_md is not None:
|
||||||
|
cwd_agents = scan_agent_names(manifest.cwd_md / "agents")
|
||||||
|
merged = {**home_agents, **cwd_agents}
|
||||||
|
path = merged.get(agent_name)
|
||||||
|
if path is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
fm, _ = parse_frontmatter(path.read_text())
|
||||||
|
bottle = fm.get("bottle", "")
|
||||||
|
return str(bottle) if isinstance(bottle, str) else ""
|
||||||
|
except (OSError, YamlSubsetError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _text_prompt_yes() -> bool:
|
||||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||||
controlling tty via stderr prompt + tty-line read."""
|
controlling tty via stderr prompt + tty-line read."""
|
||||||
@@ -198,30 +390,141 @@ def _text_prompt_yes() -> bool:
|
|||||||
return reply in ("y", "Y", "yes", "YES")
|
return reply in ("y", "Y", "yes", "YES")
|
||||||
|
|
||||||
|
|
||||||
def _text_render_preflight(*, remote_control: bool):
|
def _text_render_preflight():
|
||||||
def _render(plan: DockerBottlePlan) -> None:
|
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
||||||
plan.print(remote_control=remote_control)
|
print(file=sys.stderr)
|
||||||
|
print(f"backend: {backend_name}", file=sys.stderr)
|
||||||
|
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||||
return _render
|
return _render
|
||||||
|
|
||||||
|
|
||||||
|
def _bottle_lineage(manifest: ManifestIndex) -> dict[str, str]:
|
||||||
|
"""Return {bottle_name: lineage_label} for bottles that have an extends chain.
|
||||||
|
|
||||||
|
Bottles without a parent are omitted (the caller falls back to the bare name).
|
||||||
|
Labels show the chain root-first: e.g. 'dev -> bot-bottle-dev -> claude-dev'."""
|
||||||
|
if manifest.home_md is None:
|
||||||
|
return {}
|
||||||
|
bottles_dir = manifest.home_md / "bottles"
|
||||||
|
if not bottles_dir.is_dir():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
from ..yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
|
extends_of: dict[str, str] = {}
|
||||||
|
for path in bottles_dir.glob("*.md"):
|
||||||
|
try:
|
||||||
|
fm, _ = parse_frontmatter(path.read_text())
|
||||||
|
parent = fm.get("extends", "")
|
||||||
|
if isinstance(parent, str) and parent:
|
||||||
|
extends_of[path.stem] = parent
|
||||||
|
except (OSError, YamlSubsetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
labels: dict[str, str] = {}
|
||||||
|
for name in extends_of:
|
||||||
|
chain = [name]
|
||||||
|
seen = {name}
|
||||||
|
cur = name
|
||||||
|
while cur in extends_of:
|
||||||
|
par = extends_of[cur]
|
||||||
|
if par in seen:
|
||||||
|
break
|
||||||
|
chain.append(par)
|
||||||
|
seen.add(par)
|
||||||
|
cur = par
|
||||||
|
labels[name] = " -> ".join(reversed(chain))
|
||||||
|
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_to_yaml(manifest: Manifest) -> str:
|
||||||
|
"""Serialize the resolved Manifest to a YAML string for preflight display."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
agent = manifest.agent
|
||||||
|
lines.append("agent:")
|
||||||
|
if agent.skills:
|
||||||
|
lines.append(" skills:")
|
||||||
|
for s in agent.skills:
|
||||||
|
lines.append(f" - {s}")
|
||||||
|
if not agent.git_user.is_empty():
|
||||||
|
lines.append(" git-gate:")
|
||||||
|
lines.append(" user:")
|
||||||
|
if agent.git_user.name:
|
||||||
|
lines.append(f" name: {agent.git_user.name}")
|
||||||
|
if agent.git_user.email:
|
||||||
|
lines.append(f" email: {agent.git_user.email}")
|
||||||
|
|
||||||
|
bottle = manifest.bottle
|
||||||
|
lines.append("bottle:")
|
||||||
|
|
||||||
|
if bottle.agent_provider.template != "claude" or bottle.agent_provider.dockerfile:
|
||||||
|
lines.append(" agent_provider:")
|
||||||
|
lines.append(f" template: {bottle.agent_provider.template}")
|
||||||
|
if bottle.agent_provider.dockerfile:
|
||||||
|
lines.append(f" dockerfile: {bottle.agent_provider.dockerfile}")
|
||||||
|
|
||||||
|
if bottle.env:
|
||||||
|
lines.append(" env:")
|
||||||
|
for k, v in sorted(bottle.env.items()):
|
||||||
|
lines.append(f" {k}: {v}")
|
||||||
|
|
||||||
|
has_git_gate = not bottle.git_user.is_empty() or bottle.git
|
||||||
|
if has_git_gate:
|
||||||
|
lines.append(" git-gate:")
|
||||||
|
if not bottle.git_user.is_empty():
|
||||||
|
lines.append(" user:")
|
||||||
|
if bottle.git_user.name:
|
||||||
|
lines.append(f" name: {bottle.git_user.name}")
|
||||||
|
if bottle.git_user.email:
|
||||||
|
lines.append(f" email: {bottle.git_user.email}")
|
||||||
|
if bottle.git:
|
||||||
|
lines.append(" repos:")
|
||||||
|
for entry in bottle.git:
|
||||||
|
lines.append(f" {entry.Name}:")
|
||||||
|
lines.append(f" url: {entry.Upstream}")
|
||||||
|
|
||||||
|
if bottle.egress.routes:
|
||||||
|
lines.append(" egress:")
|
||||||
|
lines.append(" routes:")
|
||||||
|
for r in bottle.egress.routes:
|
||||||
|
lines.append(f" - host: {r.Host}")
|
||||||
|
if r.AuthScheme:
|
||||||
|
lines.append(f" auth:")
|
||||||
|
lines.append(f" scheme: {r.AuthScheme}")
|
||||||
|
|
||||||
|
lines.append(f" supervise: {'true' if bottle.supervise else 'false'}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _launch_bottle(
|
def _launch_bottle(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
remote_control: bool,
|
|
||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
|
assume_yes: bool = False,
|
||||||
|
headless_prompt_text: str = "",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
||||||
attaches claude, and prints the resume hint on session end."""
|
attaches claude, and prints the resume hint on session end.
|
||||||
|
|
||||||
|
`assume_yes` skips the interactive y/N confirmation (headless /
|
||||||
|
orchestrator launches), where there is no human at the prompt.
|
||||||
|
|
||||||
|
`headless_prompt_text` is passed to the provider's `headless_prompt`
|
||||||
|
method and the resulting args are appended to startup_args so the
|
||||||
|
agent receives the initial task without interactive input."""
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
||||||
identity = ""
|
identity = ""
|
||||||
try:
|
try:
|
||||||
plan, identity = prepare_with_preflight(
|
plan, identity = prepare_with_preflight(
|
||||||
spec,
|
spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
render_preflight=_text_render_preflight(remote_control=remote_control),
|
render_preflight=_text_render_preflight(),
|
||||||
prompt_yes=_text_prompt_yes,
|
prompt_yes=(lambda: True) if assume_yes else _text_prompt_yes,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
@@ -231,10 +534,17 @@ def _launch_bottle(
|
|||||||
backend = get_bottle_backend(backend_name)
|
backend = get_bottle_backend(backend_name)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
|
extra_args: tuple[str, ...] = ()
|
||||||
|
if headless_prompt_text:
|
||||||
|
extra_args = tuple(
|
||||||
|
get_provider(agent_provider_template).headless_prompt(
|
||||||
|
headless_prompt_text
|
||||||
|
)
|
||||||
|
)
|
||||||
exit_code = attach_agent(
|
exit_code = attach_agent(
|
||||||
bottle,
|
bottle,
|
||||||
remote_control=remote_control,
|
|
||||||
agent_provider_template=agent_provider_template,
|
agent_provider_template=agent_provider_template,
|
||||||
|
startup_args=plan.agent_provision.startup_args + extra_args,
|
||||||
)
|
)
|
||||||
info(
|
info(
|
||||||
f"session ended (exit {exit_code}); "
|
f"session ended (exit {exit_code}); "
|
||||||
@@ -242,12 +552,8 @@ def _launch_bottle(
|
|||||||
)
|
)
|
||||||
# While the container is still alive: always snapshot the
|
# While the container is still alive: always snapshot the
|
||||||
# transcript and — if the agent exited non-zero — mark
|
# transcript and — if the agent exited non-zero — mark
|
||||||
# the state for preservation. Capability-block already
|
# the state for preservation. This picks up crashes /
|
||||||
# did both before triggering teardown from the dashboard;
|
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||||
# 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":
|
if agent_provider_template == "claude":
|
||||||
capture_claude_session_state(identity, exit_code)
|
capture_claude_session_state(identity, exit_code)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+89
-59
@@ -2,8 +2,8 @@
|
|||||||
act on them (approve / modify / reject).
|
act on them (approve / modify / reject).
|
||||||
|
|
||||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||||
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
Egress proposals are queued for operator review as full routes.yaml
|
||||||
the bottle Dockerfile. The egress-block tool was removed in issue #198.
|
updates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,17 +20,16 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import supervise as _supervise
|
from .. import supervise as _supervise
|
||||||
# from ..bottle_state import read_metadata
|
from ..bottle_state import read_metadata
|
||||||
# from ..backend.docker.capability_apply import (
|
from ..backend.docker.egress_apply import (
|
||||||
# CapabilityApplyError,
|
EgressApplyError,
|
||||||
# apply_capability_change,
|
applicator as _docker_applicator,
|
||||||
# )
|
)
|
||||||
|
from ..backend.macos_container.egress_apply import (
|
||||||
|
applicator as _macos_applicator,
|
||||||
|
)
|
||||||
from ..log import Die, error, info
|
from ..log import Die, error, info
|
||||||
|
|
||||||
|
|
||||||
class CapabilityApplyError(RuntimeError):
|
|
||||||
"""Placeholder while capability_apply is disabled."""
|
|
||||||
|
|
||||||
from ..supervise import (
|
from ..supervise import (
|
||||||
COMPONENT_FOR_TOOL,
|
COMPONENT_FOR_TOOL,
|
||||||
AuditEntry,
|
AuditEntry,
|
||||||
@@ -39,9 +38,11 @@ from ..supervise import (
|
|||||||
STATUS_APPROVED,
|
STATUS_APPROVED,
|
||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_EGRESS_ALLOW,
|
||||||
archive_proposal,
|
TOOL_EGRESS_BLOCK,
|
||||||
list_pending_proposals,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
|
list_all_pending_proposals,
|
||||||
render_diff,
|
render_diff,
|
||||||
write_audit_entry,
|
write_audit_entry,
|
||||||
write_response,
|
write_response,
|
||||||
@@ -51,32 +52,39 @@ from ._common import PROG
|
|||||||
|
|
||||||
_REFRESH_INTERVAL_MS = 1000
|
_REFRESH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
# Proposal tools whose payload is a read-only report, not a file the operator
|
||||||
|
# edits: modify is unavailable and approval requires a recorded reason for the
|
||||||
|
# audit trail.
|
||||||
|
_REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QueuedProposal:
|
class QueuedProposal:
|
||||||
"""A pending proposal plus the queue dir it was found in."""
|
"""A pending proposal from the supervise queue."""
|
||||||
|
|
||||||
proposal: Proposal
|
proposal: Proposal
|
||||||
queue_dir: Path
|
|
||||||
|
|
||||||
|
|
||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
# handlers and surfaced in the status line so a failed apply keeps
|
# handlers and surfaced in the status line so a failed apply keeps
|
||||||
# the proposal pending rather than crashing curses.
|
# the proposal pending rather than crashing curses.
|
||||||
ApplyError = (CapabilityApplyError,)
|
ApplyError = (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)
|
||||||
|
return _docker_applicator.apply_routes_change(slug, content)
|
||||||
|
|
||||||
|
|
||||||
def discover_pending() -> list[QueuedProposal]:
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
|
"""Collect pending proposals across bottles."""
|
||||||
queue_root = _supervise.bot_bottle_root() / "queue"
|
out = [
|
||||||
if not queue_root.is_dir():
|
QueuedProposal(proposal=proposal)
|
||||||
return []
|
for proposal in list_all_pending_proposals()
|
||||||
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)
|
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -99,7 +107,6 @@ def _detail_lines(
|
|||||||
(f"tool: {p.tool}", 0),
|
(f"tool: {p.tool}", 0),
|
||||||
(f"id: {p.id}", 0),
|
(f"id: {p.id}", 0),
|
||||||
(f"arrived: {p.arrival_timestamp}", 0),
|
(f"arrived: {p.arrival_timestamp}", 0),
|
||||||
(f"queue: {qp.queue_dir}", 0),
|
|
||||||
("", 0),
|
("", 0),
|
||||||
("justification:", 0),
|
("justification:", 0),
|
||||||
]
|
]
|
||||||
@@ -113,8 +120,10 @@ def _detail_lines(
|
|||||||
|
|
||||||
|
|
||||||
def _suffix_for_tool(tool: str) -> str:
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||||
return ".dockerfile"
|
return ".yaml"
|
||||||
|
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
|
||||||
|
return ".txt"
|
||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
@@ -129,19 +138,14 @@ def approve(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Apply the proposal, write the waiting response, and audit it."""
|
"""Apply the proposal, write the waiting response, and audit it."""
|
||||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
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 = "", ""
|
diff_before, diff_after = "", ""
|
||||||
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||||
# _meta = read_metadata(qp.proposal.bottle_slug)
|
diff_before, diff_after = apply_routes_change(
|
||||||
# if _meta is not None and not _meta.compose_project:
|
qp.proposal.bottle_slug,
|
||||||
# raise CapabilityApplyError(
|
file_to_apply,
|
||||||
# "capability-block remediation is not supported for smolmachines "
|
)
|
||||||
# "bottles. Reject this proposal or handle the capability change "
|
|
||||||
# "manually, then restart the bottle."
|
|
||||||
# )
|
|
||||||
# diff_before, diff_after = apply_capability_change(
|
|
||||||
# qp.proposal.bottle_slug, file_to_apply,
|
|
||||||
# )
|
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
proposal_id=qp.proposal.id,
|
proposal_id=qp.proposal.id,
|
||||||
@@ -149,14 +153,11 @@ def approve(
|
|||||||
notes=notes,
|
notes=notes,
|
||||||
final_file=final_file,
|
final_file=final_file,
|
||||||
)
|
)
|
||||||
write_response(qp.queue_dir, response)
|
write_response(qp.proposal.bottle_slug, response)
|
||||||
_write_audit(
|
_write_audit(
|
||||||
qp, action=status, notes=notes,
|
qp, action=status, notes=notes,
|
||||||
diff_before=diff_before, diff_after=diff_after,
|
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:
|
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||||
"""Write a rejection response and an audit entry."""
|
"""Write a rejection response and an audit entry."""
|
||||||
@@ -166,10 +167,27 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
|||||||
notes=reason,
|
notes=reason,
|
||||||
final_file=None,
|
final_file=None,
|
||||||
)
|
)
|
||||||
write_response(qp.queue_dir, response)
|
write_response(qp.proposal.bottle_slug, response)
|
||||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
_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 in _REPORT_ONLY_TOOLS and final_file is None:
|
||||||
|
notes = _prompt(stdscr, "allow reason (false positive / legitimately needed): ")
|
||||||
|
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(
|
def _write_audit(
|
||||||
qp: QueuedProposal,
|
qp: QueuedProposal,
|
||||||
*,
|
*,
|
||||||
@@ -241,7 +259,10 @@ def cmd_supervise(argv: list[str]) -> int:
|
|||||||
return e.code if isinstance(e.code, int) else 1
|
return e.code if isinstance(e.code, int) else 1
|
||||||
except Exception as e: # noqa: W0718 — catch supervise crash for logging
|
except Exception as e: # noqa: W0718 — catch supervise crash for logging
|
||||||
log_path = _write_crash_log(e)
|
log_path = _write_crash_log(e)
|
||||||
error(f"supervise crashed: {type(e).__name__}: {e}")
|
error(
|
||||||
|
f"supervise crashed: {type(e).__name__}: {e}",
|
||||||
|
context={"error_type": type(e).__name__, "crash_log": str(log_path)},
|
||||||
|
)
|
||||||
error(f"full traceback written to {log_path}")
|
error(f"full traceback written to {log_path}")
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
@@ -286,7 +307,7 @@ def _list_once() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _try_init_green() -> int:
|
def _try_init_green() -> int: # pragma: no cover
|
||||||
"""Initialise a green color pair and return its attr, or 0."""
|
"""Initialise a green color pair and return its attr, or 0."""
|
||||||
try:
|
try:
|
||||||
curses.start_color()
|
curses.start_color()
|
||||||
@@ -297,7 +318,7 @@ def _try_init_green() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragma: no cover
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
stdscr.timeout(_REFRESH_INTERVAL_MS)
|
stdscr.timeout(_REFRESH_INTERVAL_MS)
|
||||||
green_attr = _try_init_green()
|
green_attr = _try_init_green()
|
||||||
@@ -353,18 +374,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
|||||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
status_line = _approve_from_tui(stdscr, qp)
|
||||||
status_line = _approval_status(qp, "approved")
|
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||||
|
status_line = f"modify unavailable for {qp.proposal.tool}"
|
||||||
|
continue
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is None:
|
if edited is None:
|
||||||
status_line = "modify aborted (no change)"
|
status_line = "modify aborted (no change)"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
status_line = _approve_from_tui(
|
||||||
status_line = _approval_status(qp, "modified+approved")
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("r"):
|
elif key == ord("r"):
|
||||||
@@ -383,7 +408,7 @@ def _render(
|
|||||||
status_line: str,
|
status_line: str,
|
||||||
*,
|
*,
|
||||||
green_attr: int = 0, # noqa: F841 — unused, but required by interface
|
green_attr: int = 0, # noqa: F841 — unused, but required by interface
|
||||||
) -> None:
|
) -> None: # pragma: no cover
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
h, w = stdscr.getmaxyx()
|
h, w = stdscr.getmaxyx()
|
||||||
header = f"bot-bottle supervise ({len(pending)} pending)"
|
header = f"bot-bottle supervise ({len(pending)} pending)"
|
||||||
@@ -434,7 +459,7 @@ def _detail_view(
|
|||||||
qp: QueuedProposal,
|
qp: QueuedProposal,
|
||||||
*,
|
*,
|
||||||
green_attr: int = 0,
|
green_attr: int = 0,
|
||||||
) -> None:
|
) -> None: # pragma: no cover
|
||||||
"""Render the full proposal. Scrollable. Press q to return."""
|
"""Render the full proposal. Scrollable. Press q to return."""
|
||||||
lines = _detail_lines(qp, green_attr=green_attr)
|
lines = _detail_lines(qp, green_attr=green_attr)
|
||||||
offset = 0
|
offset = 0
|
||||||
@@ -462,15 +487,20 @@ def _detail_view(
|
|||||||
offset = max(0, len(lines) - 1)
|
offset = max(0, len(lines) - 1)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
_approve_from_tui(stdscr, qp)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||||
|
return
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is not None:
|
if edited is not None:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
_approve_from_tui(
|
||||||
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
@@ -481,7 +511,7 @@ def _detail_view(
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore
|
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
|
||||||
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
||||||
suffix = _suffix_for_tool(qp.proposal.tool)
|
suffix = _suffix_for_tool(qp.proposal.tool)
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
@@ -492,7 +522,7 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
|
|||||||
return edited
|
return edited
|
||||||
|
|
||||||
|
|
||||||
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore
|
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore # pragma: no cover
|
||||||
"""One-line input at the bottom of the screen."""
|
"""One-line input at the bottom of the screen."""
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
h, _ = stdscr.getmaxyx()
|
h, _ = stdscr.getmaxyx()
|
||||||
|
|||||||
+319
-19
@@ -17,6 +17,43 @@ import sys
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def filter_multiselect(
|
||||||
|
items: list[str],
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
initial: Optional[list[str]] = None,
|
||||||
|
tty_path: str = "/dev/tty",
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
"""Render a multi-select picker over *items*.
|
||||||
|
|
||||||
|
Returns the ordered list of selected items, or ``None`` if the user
|
||||||
|
cancelled (Esc / ``q`` / Ctrl-C / Ctrl-D with no items).
|
||||||
|
|
||||||
|
Press Space to toggle the item under the cursor.
|
||||||
|
Press Enter to confirm the current selection.
|
||||||
|
Press Ctrl-D to confirm the current selection (returns even if empty).
|
||||||
|
Press Esc/q to cancel (returns None).
|
||||||
|
|
||||||
|
*initial* pre-populates the selection in insertion order. Items
|
||||||
|
added are appended; removed items leave the remaining order unchanged.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd_dup = os.dup(tty_fd.fileno())
|
||||||
|
return _run_multiselect(
|
||||||
|
items, title=title, initial=list(initial or []), tty_fd=fd_dup
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tty_fd.close()
|
||||||
|
|
||||||
|
|
||||||
def filter_select(
|
def filter_select(
|
||||||
items: list[str],
|
items: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -221,25 +258,283 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# filter_multiselect internals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_KEY_SPACE = 32
|
||||||
|
|
||||||
|
|
||||||
|
def _run_multiselect(
|
||||||
|
items: list[str], *, title: str, initial: list[str], tty_fd: int
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
"""Drive a curses multi-select session on *tty_fd*."""
|
||||||
|
os.environ.setdefault("TERM", "xterm-256color")
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
screen = curses.initscr()
|
||||||
|
curses.noecho()
|
||||||
|
curses.cbreak()
|
||||||
|
screen.keypad(True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _multiselect_loop(screen, items, title=title, initial=initial)
|
||||||
|
finally:
|
||||||
|
screen.keypad(False)
|
||||||
|
curses.nocbreak()
|
||||||
|
curses.echo()
|
||||||
|
curses.endwin()
|
||||||
|
except Exception: # noqa: W0718
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||||
|
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_membership(items: list[str], item: str) -> None:
|
||||||
|
"""Add `item` if absent, remove it if present (in place)."""
|
||||||
|
if item in items:
|
||||||
|
items.remove(item)
|
||||||
|
else:
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_order_key(key: int, selected: list[str], order_cursor: int) -> int:
|
||||||
|
"""Apply a keypress in 'order' focus: navigate, reorder, or remove the
|
||||||
|
item at `order_cursor`. Mutates `selected` in place and returns the new
|
||||||
|
order cursor."""
|
||||||
|
if key in (curses.KEY_UP, ord("k")):
|
||||||
|
if order_cursor > 0:
|
||||||
|
order_cursor -= 1
|
||||||
|
elif key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
if order_cursor < len(selected) - 1:
|
||||||
|
order_cursor += 1
|
||||||
|
elif key == ord("K"):
|
||||||
|
# Move selected item up (earlier in order).
|
||||||
|
if order_cursor > 0:
|
||||||
|
i = order_cursor
|
||||||
|
selected[i - 1], selected[i] = selected[i], selected[i - 1]
|
||||||
|
order_cursor -= 1
|
||||||
|
elif key == ord("J"):
|
||||||
|
# Move selected item down (later in order).
|
||||||
|
if order_cursor < len(selected) - 1:
|
||||||
|
i = order_cursor
|
||||||
|
selected[i], selected[i + 1] = selected[i + 1], selected[i]
|
||||||
|
order_cursor += 1
|
||||||
|
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
|
||||||
|
# Remove item from selection while in order mode.
|
||||||
|
del selected[order_cursor]
|
||||||
|
if order_cursor >= len(selected) and order_cursor > 0:
|
||||||
|
order_cursor -= 1
|
||||||
|
return order_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def _multiselect_loop(
|
||||||
|
screen: Any, items: list[str], *, title: str, initial: list[str]
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
query = ""
|
||||||
|
cursor = 0
|
||||||
|
selected: list[str] = [s for s in initial if s in items]
|
||||||
|
# focus = "filter": navigate + toggle items in the filterable list
|
||||||
|
# focus = "order": navigate + reorder items in the selected list
|
||||||
|
focus = "filter"
|
||||||
|
order_cursor = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
filtered = _filter_items(items, query)
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
cursor = 0
|
||||||
|
elif cursor >= len(filtered):
|
||||||
|
cursor = len(filtered) - 1
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
order_cursor = 0
|
||||||
|
if focus == "order":
|
||||||
|
focus = "filter"
|
||||||
|
elif order_cursor >= len(selected):
|
||||||
|
order_cursor = len(selected) - 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
_render_multiselect(
|
||||||
|
screen, filtered, cursor,
|
||||||
|
query=query, title=title, selected=selected,
|
||||||
|
focus=focus, order_cursor=order_cursor,
|
||||||
|
)
|
||||||
|
except curses.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = screen.getch()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in (_KEY_ESC, _KEY_CTRL_C, ord("q")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == _KEY_CTRL_D:
|
||||||
|
return list(selected)
|
||||||
|
|
||||||
|
# Tab toggles between filter and order focus.
|
||||||
|
if key == ord("\t"):
|
||||||
|
if focus == "filter" and selected:
|
||||||
|
focus = "order"
|
||||||
|
order_cursor = 0
|
||||||
|
else:
|
||||||
|
focus = "filter"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if focus == "filter":
|
||||||
|
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
||||||
|
return list(selected)
|
||||||
|
|
||||||
|
elif key == _KEY_SPACE:
|
||||||
|
if filtered:
|
||||||
|
_toggle_membership(selected, filtered[cursor])
|
||||||
|
|
||||||
|
elif 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]
|
||||||
|
new_filtered = _filter_items(items, query)
|
||||||
|
if cursor >= len(new_filtered):
|
||||||
|
cursor = max(0, len(new_filtered) - 1)
|
||||||
|
|
||||||
|
elif 32 <= key <= 126 and key != _KEY_SPACE:
|
||||||
|
query += chr(key)
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
else: # focus == "order"
|
||||||
|
order_cursor = _handle_order_key(key, selected, order_cursor)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_multiselect(
|
||||||
|
screen: Any,
|
||||||
|
filtered: list[str],
|
||||||
|
cursor: int,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
title: str,
|
||||||
|
selected: list[str],
|
||||||
|
focus: str = "filter",
|
||||||
|
order_cursor: int = 0,
|
||||||
|
) -> None:
|
||||||
|
screen.erase()
|
||||||
|
rows, cols = screen.getmaxyx()
|
||||||
|
min_rows = 7
|
||||||
|
|
||||||
|
if rows < min_rows:
|
||||||
|
raise curses.error("terminal too small")
|
||||||
|
|
||||||
|
sep = "─" * min(cols - 1, 40)
|
||||||
|
row = 0
|
||||||
|
|
||||||
|
if title and row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Filter line — dim when focus is on the order panel.
|
||||||
|
filter_label = f"Filter: {query}"
|
||||||
|
filter_hint = " [Tab: reorder]" if focus == "filter" and selected else ""
|
||||||
|
filter_attr = curses.A_DIM if focus == "order" else curses.A_NORMAL
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, (filter_label + filter_hint)[:cols - 1], filter_attr)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, sep)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Compute how many rows the bottom order panel needs.
|
||||||
|
# Cap the visible selected list to keep the filter list legible.
|
||||||
|
order_rows = min(len(selected), max(1, (rows - row) // 3)) if selected else 0
|
||||||
|
# Bottom reserved: sep + order_rows + sep + help = order_rows + 3
|
||||||
|
bottom_reserved = order_rows + 3
|
||||||
|
|
||||||
|
list_start = row
|
||||||
|
list_rows = rows - list_start - bottom_reserved
|
||||||
|
if list_rows < 1:
|
||||||
|
list_rows = 1
|
||||||
|
|
||||||
|
selected_set = set(selected)
|
||||||
|
filter_dim = focus == "order"
|
||||||
|
scroll = max(0, cursor - list_rows + 1)
|
||||||
|
visible = filtered[scroll: scroll + list_rows]
|
||||||
|
|
||||||
|
for idx, item in enumerate(visible):
|
||||||
|
abs_idx = scroll + idx
|
||||||
|
mark = "[*]" if item in selected_set else "[ ]"
|
||||||
|
prefix = "> " if (abs_idx == cursor and focus == "filter") else " "
|
||||||
|
line = (prefix + mark + " " + item)[:cols - 1]
|
||||||
|
item_attr = curses.A_DIM if filter_dim else (
|
||||||
|
curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
|
||||||
|
)
|
||||||
|
if row < rows - bottom_reserved:
|
||||||
|
_addstr_safe(screen, row, 0, line, item_attr)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Separator before the order panel.
|
||||||
|
if row < rows - (order_rows + 2):
|
||||||
|
_addstr_safe(screen, row, 0, sep)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Order panel.
|
||||||
|
order_scroll = max(0, order_cursor - order_rows + 1)
|
||||||
|
order_visible = selected[order_scroll: order_scroll + order_rows]
|
||||||
|
for idx, item in enumerate(order_visible):
|
||||||
|
abs_idx = order_scroll + idx
|
||||||
|
is_active = focus == "order" and abs_idx == order_cursor
|
||||||
|
prefix = "> " if is_active else " "
|
||||||
|
line = (prefix + item)[:cols - 1]
|
||||||
|
attr = curses.A_REVERSE if is_active else curses.A_NORMAL
|
||||||
|
if row < rows - 2:
|
||||||
|
_addstr_safe(screen, row, 0, line, attr)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, sep)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if focus == "filter":
|
||||||
|
help_line = "[↑↓/jk] move [Space] toggle [Enter] confirm [Tab] reorder [Esc/q] cancel"
|
||||||
|
else:
|
||||||
|
help_line = "[↑↓/jk] cursor [K/J] reorder [Space/Enter] remove [Tab] back [Ctrl-D] done"
|
||||||
|
if row < rows:
|
||||||
|
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
|
||||||
|
|
||||||
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# name_color_modal — two-step label + color picker
|
# name_color_modal — two-step label + color picker
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_ANSI_COLORS = [
|
_ANSI_COLORS = [
|
||||||
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
|
"red", "green", "yellow", "blue", "magenta",
|
||||||
"bright-red", "bright-green", "bright-blue", "bright-yellow",
|
|
||||||
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_CURSES_COLOR_MAP: dict[str, int] = {
|
_CURSES_COLOR_MAP: dict[str, int] = {
|
||||||
"black": curses.COLOR_BLACK,
|
|
||||||
"red": curses.COLOR_RED,
|
"red": curses.COLOR_RED,
|
||||||
"green": curses.COLOR_GREEN,
|
"green": curses.COLOR_GREEN,
|
||||||
"yellow": curses.COLOR_YELLOW,
|
"yellow": curses.COLOR_YELLOW,
|
||||||
"blue": curses.COLOR_BLUE,
|
"blue": curses.COLOR_BLUE,
|
||||||
"magenta": curses.COLOR_MAGENTA,
|
"magenta": curses.COLOR_MAGENTA,
|
||||||
"cyan": curses.COLOR_CYAN,
|
|
||||||
"white": curses.COLOR_WHITE,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_COLOR_NONE = "(none)"
|
_COLOR_NONE = "(none)"
|
||||||
@@ -248,11 +543,15 @@ _COLOR_NONE = "(none)"
|
|||||||
def name_color_modal(
|
def name_color_modal(
|
||||||
default_label: str,
|
default_label: str,
|
||||||
*,
|
*,
|
||||||
|
disclaimer: str = "",
|
||||||
tty_path: str = "/dev/tty",
|
tty_path: str = "/dev/tty",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Present a two-step curses modal: first edit the agent label,
|
"""Present a two-step curses modal: first edit the agent label,
|
||||||
then optionally pick a color.
|
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
|
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
||||||
color name strings or ``""`` for no color. Falls back to
|
color name strings or ``""`` for no color. Falls back to
|
||||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
``(default_label, "")`` on any error (terminal too small, not a tty).
|
||||||
@@ -264,14 +563,14 @@ def name_color_modal(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
fd_dup = os.dup(tty_fd.fileno())
|
fd_dup = os.dup(tty_fd.fileno())
|
||||||
return _run_name_color(default_label, tty_fd=fd_dup)
|
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
return default_label, ""
|
return default_label, ""
|
||||||
finally:
|
finally:
|
||||||
tty_fd.close()
|
tty_fd.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
|
||||||
import io
|
import io
|
||||||
orig_stdin = sys.__stdin__
|
orig_stdin = sys.__stdin__
|
||||||
orig_stdout = sys.__stdout__
|
orig_stdout = sys.__stdout__
|
||||||
@@ -286,7 +585,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
curses.cbreak()
|
curses.cbreak()
|
||||||
screen.keypad(True)
|
screen.keypad(True)
|
||||||
try:
|
try:
|
||||||
label = _label_step(screen, default_label)
|
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
||||||
color = _color_step(screen, label)
|
color = _color_step(screen, label)
|
||||||
finally:
|
finally:
|
||||||
screen.keypad(False)
|
screen.keypad(False)
|
||||||
@@ -299,14 +598,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
|||||||
return label, color
|
return label, color
|
||||||
|
|
||||||
|
|
||||||
def _label_step(screen: Any, default_label: str) -> str:
|
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
||||||
"""Step 1: edit the label. First printable key replaces the
|
"""Step 1: edit the label. First printable key replaces the
|
||||||
pre-fill; subsequent keys append. Enter confirms."""
|
pre-fill; subsequent keys append. Enter confirms."""
|
||||||
text = default_label
|
text = default_label
|
||||||
replaced = False # True once the user has typed their first char
|
replaced = False # True once the user has typed their first char
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_render_label(screen, text)
|
_render_label(screen, text, disclaimer=disclaimer)
|
||||||
try:
|
try:
|
||||||
key = screen.getch()
|
key = screen.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -330,7 +629,7 @@ def _label_step(screen: Any, default_label: str) -> str:
|
|||||||
text += chr(key)
|
text += chr(key)
|
||||||
|
|
||||||
|
|
||||||
def _render_label(screen: Any, text: str) -> None:
|
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
||||||
screen.erase()
|
screen.erase()
|
||||||
rows, cols = screen.getmaxyx()
|
rows, cols = screen.getmaxyx()
|
||||||
sep = "─" * min(cols - 1, 40)
|
sep = "─" * min(cols - 1, 40)
|
||||||
@@ -338,8 +637,12 @@ def _render_label(screen: Any, text: str) -> None:
|
|||||||
_addstr_safe(screen, 1, 0, sep)
|
_addstr_safe(screen, 1, 0, sep)
|
||||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
||||||
_addstr_safe(screen, 3, 0, sep)
|
_addstr_safe(screen, 3, 0, sep)
|
||||||
if rows > 5:
|
row = 4
|
||||||
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
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()
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
@@ -379,13 +682,10 @@ def _init_color_pairs() -> dict[str, int]:
|
|||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
pair_idx = 2 # pair 1 reserved for other uses
|
pair_idx = 2 # pair 1 reserved for other uses
|
||||||
for name in _ANSI_COLORS:
|
for name in _ANSI_COLORS:
|
||||||
base = name.replace("bright-", "")
|
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
|
||||||
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
|
|
||||||
try:
|
try:
|
||||||
curses.init_pair(pair_idx, fg, -1)
|
curses.init_pair(pair_idx, fg, -1)
|
||||||
attr = curses.color_pair(pair_idx)
|
attr = curses.color_pair(pair_idx) | curses.A_BOLD
|
||||||
if name.startswith("bright-"):
|
|
||||||
attr |= curses.A_BOLD
|
|
||||||
attrs[name] = attr
|
attrs[name] = attr
|
||||||
pair_idx += 1
|
pair_idx += 1
|
||||||
except curses.error:
|
except curses.error:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ FROM node:22-slim
|
|||||||
# to it) works against egress's bumped TLS without the agent needing
|
# to it) works against egress's bumped TLS without the agent needing
|
||||||
# local DNS.
|
# 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 curl ripgrep iproute2 dnsutils \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by claude-code itself
|
# App-specific deps. Python isn't required by claude-code itself
|
||||||
@@ -36,7 +36,7 @@ RUN apt-get update \
|
|||||||
# 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.126 \
|
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||||
&& 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
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ from typing import TYPE_CHECKING
|
|||||||
from ...agent_provider import (
|
from ...agent_provider import (
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentProviderRuntime,
|
AgentProviderRuntime,
|
||||||
|
AgentProvisionDir,
|
||||||
AgentProvisionFile,
|
AgentProvisionFile,
|
||||||
AgentProvisionPlan,
|
AgentProvisionPlan,
|
||||||
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
|
from ...backend.docker import util as docker_mod
|
||||||
from ...egress import EgressRoute
|
from ...egress import EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
|
||||||
@@ -38,6 +41,49 @@ def _skills_dir(guest_home: str) -> str:
|
|||||||
def _prompt_path(guest_home: str) -> str:
|
def _prompt_path(guest_home: str) -> str:
|
||||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
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(
|
_RUNTIME = AgentProviderRuntime(
|
||||||
template="claude",
|
template="claude",
|
||||||
command="claude",
|
command="claude",
|
||||||
@@ -45,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
prompt_mode="append_file",
|
prompt_mode="append_file",
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
resume_args=("--continue",),
|
resume_args=("--continue",),
|
||||||
remote_control_args=("--remote-control",),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,9 +113,11 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
label: str = "",
|
label: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del forward_host_credentials, host_env # Codex-only knobs
|
del forward_host_credentials, host_env
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
trusted_path = trusted_project_path or guest_home
|
trusted_path = trusted_project_path or guest_home
|
||||||
|
|
||||||
@@ -78,6 +125,10 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||||
"DISABLE_ERROR_REPORTING": "1",
|
"DISABLE_ERROR_REPORTING": "1",
|
||||||
}
|
}
|
||||||
|
dirs = (
|
||||||
|
AgentProvisionDir(f"{guest_home}/.claude"),
|
||||||
|
AgentProvisionDir(f"{guest_home}/.claude/themes"),
|
||||||
|
)
|
||||||
claude_config = state_dir / "claude.json"
|
claude_config = state_dir / "claude.json"
|
||||||
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
|
||||||
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
|
||||||
@@ -87,15 +138,45 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
"bypassPermissionsModeAccepted": True,
|
"bypassPermissionsModeAccepted": True,
|
||||||
"projects": claude_projects,
|
"projects": claude_projects,
|
||||||
}
|
}
|
||||||
if label:
|
|
||||||
payload["name"] = label
|
|
||||||
if color:
|
|
||||||
payload["color"] = color
|
|
||||||
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
claude_config.chmod(0o600)
|
claude_config.chmod(0o600)
|
||||||
files = (
|
files = [
|
||||||
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
|
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(
|
egress_routes = (EgressRoute(
|
||||||
host="api.anthropic.com",
|
host="api.anthropic.com",
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
auth_scheme="Bearer" if auth_token else "",
|
||||||
@@ -106,6 +187,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
|
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
||||||
return AgentProvisionPlan(
|
return AgentProvisionPlan(
|
||||||
template=_RUNTIME.template,
|
template=_RUNTIME.template,
|
||||||
command=_RUNTIME.command,
|
command=_RUNTIME.command,
|
||||||
@@ -117,7 +199,10 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
env_vars=env_vars,
|
env_vars=env_vars,
|
||||||
guest_env=resolved_guest_env,
|
guest_env=resolved_guest_env,
|
||||||
files=files,
|
has_prompt=has_prompt,
|
||||||
|
startup_args=startup_args,
|
||||||
|
dirs=dirs,
|
||||||
|
files=tuple(files),
|
||||||
egress_routes=egress_routes,
|
egress_routes=egress_routes,
|
||||||
hidden_env_names=hidden_env_names,
|
hidden_env_names=hidden_env_names,
|
||||||
)
|
)
|
||||||
@@ -128,11 +213,11 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
when the agent has no skills."""
|
when the agent has no skills."""
|
||||||
from ...backend.util import host_skill_dir
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -142,9 +227,13 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
@@ -157,8 +246,8 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
user="root",
|
user="root",
|
||||||
)
|
)
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
return prompt_path if agent.prompt else None
|
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||||
|
|
||||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
"""Apply the claude-side declarative provision steps from
|
"""Apply the claude-side declarative provision steps from
|
||||||
@@ -224,6 +313,9 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
f"claude mcp add --scope user --transport http supervise {supervise_url}"
|
f"claude mcp add --scope user --transport http supervise {supervise_url}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def headless_prompt(self, prompt: str) -> list[str]:
|
||||||
|
return ["-p", prompt]
|
||||||
|
|
||||||
|
|
||||||
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||||
result = bottle.exec(script, user="root")
|
result = bottle.exec(script, user="root")
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# bot-bottle Codex provider image.
|
# bot-bottle Codex provider image.
|
||||||
#
|
#
|
||||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||||
# non-root node user, and the provider CLI installed globally.
|
# non-root node user, and the provider CLI installed for that user.
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
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 curl procps ripgrep \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by codex itself
|
# App-specific deps. Python isn't required by codex itself
|
||||||
@@ -17,12 +17,15 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
USER node
|
||||||
WORKDIR /home/node
|
WORKDIR /home/node
|
||||||
|
|
||||||
RUN mkdir -p /home/node/.codex
|
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
# Remote-control support requires the standalone Codex install layout
|
||||||
|
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||||
|
# the TUI, but remote-control commands expect this installer-owned path.
|
||||||
|
RUN mkdir -p /home/node/.codex \
|
||||||
|
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||||
|
|
||||||
CMD ["codex"]
|
CMD ["codex"]
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ from ...agent_provider import (
|
|||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentProviderRuntime,
|
AgentProviderRuntime,
|
||||||
AgentProvisionCommand,
|
|
||||||
AgentProvisionDir,
|
AgentProvisionDir,
|
||||||
|
AgentProvisionCommand,
|
||||||
AgentProvisionFile,
|
AgentProvisionFile,
|
||||||
AgentProvisionPlan,
|
AgentProvisionPlan,
|
||||||
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||||
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
@@ -33,6 +34,12 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
|
||||||
|
_CODEX_CLI_PATH = (
|
||||||
|
"/home/node/.local/bin:"
|
||||||
|
"/home/node/.codex/packages/standalone/current/bin:"
|
||||||
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir(guest_home: str) -> str:
|
def _skills_dir(guest_home: str) -> str:
|
||||||
@@ -46,14 +53,14 @@ def _skills_dir(guest_home: str) -> str:
|
|||||||
def _prompt_path(guest_home: str) -> str:
|
def _prompt_path(guest_home: str) -> str:
|
||||||
return f"{guest_home}/.bot-bottle-prompt.txt"
|
return f"{guest_home}/.bot-bottle-prompt.txt"
|
||||||
|
|
||||||
|
|
||||||
_RUNTIME = AgentProviderRuntime(
|
_RUNTIME = AgentProviderRuntime(
|
||||||
template="codex",
|
template="codex",
|
||||||
command="codex",
|
command=_CODEX_CLI,
|
||||||
image="bot-bottle-codex:latest",
|
image="bot-bottle-codex:latest",
|
||||||
prompt_mode="read_prompt_file",
|
prompt_mode="read_prompt_file",
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
resume_args=("resume", "--last"),
|
resume_args=("resume", "--last"),
|
||||||
remote_control_args=(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,9 +83,11 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
trusted_project_path: str = "",
|
trusted_project_path: str = "",
|
||||||
label: str = "",
|
label: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del auth_token, label, color # Claude-only knobs
|
del auth_token, label, color
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
trusted_path = trusted_project_path or guest_home
|
trusted_path = trusted_project_path or guest_home
|
||||||
|
|
||||||
@@ -101,6 +110,11 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
config_file.write_text(
|
config_file.write_text(
|
||||||
f'[projects."{toml_path}"]\n'
|
f'[projects."{toml_path}"]\n'
|
||||||
'trust_level = "trusted"\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)
|
config_file.chmod(0o600)
|
||||||
files.append(AgentProvisionFile(config_file, config_path))
|
files.append(AgentProvisionFile(config_file, config_path))
|
||||||
@@ -137,12 +151,14 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
"env",
|
"env",
|
||||||
f"HOME={guest_home}",
|
f"HOME={guest_home}",
|
||||||
f"CODEX_HOME={auth_dir}",
|
f"CODEX_HOME={auth_dir}",
|
||||||
"codex", "login", "status",
|
f"PATH={_CODEX_CLI_PATH}",
|
||||||
|
_CODEX_CLI, "login", "status",
|
||||||
), (
|
), (
|
||||||
"codex host credentials: dummy auth was copied into the "
|
"codex host credentials: dummy auth was copied into the "
|
||||||
"guest, but Codex did not accept it"
|
"guest, but Codex did not accept it"
|
||||||
)))
|
)))
|
||||||
|
|
||||||
|
has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
|
||||||
return AgentProvisionPlan(
|
return AgentProvisionPlan(
|
||||||
template=_RUNTIME.template,
|
template=_RUNTIME.template,
|
||||||
command=_RUNTIME.command,
|
command=_RUNTIME.command,
|
||||||
@@ -154,6 +170,8 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
env_vars=env_vars,
|
env_vars=env_vars,
|
||||||
guest_env=resolved_guest_env,
|
guest_env=resolved_guest_env,
|
||||||
|
has_prompt=has_prompt,
|
||||||
|
startup_args=startup_args,
|
||||||
dirs=tuple(dirs),
|
dirs=tuple(dirs),
|
||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
pre_copy=tuple(pre_copy),
|
pre_copy=tuple(pre_copy),
|
||||||
@@ -168,11 +186,11 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
skills."""
|
skills."""
|
||||||
from ...backend.util import host_skill_dir
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -182,9 +200,13 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
@@ -197,8 +219,8 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
user="root",
|
user="root",
|
||||||
)
|
)
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
return prompt_path if agent.prompt else None
|
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||||
|
|
||||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
"""Apply the codex-side declarative provision steps from
|
"""Apply the codex-side declarative provision steps from
|
||||||
@@ -252,8 +274,8 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"codex mcp add --transport http "
|
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
|
||||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
f"{shlex.quote(supervise_url)}",
|
||||||
user="node",
|
user="node",
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -261,9 +283,12 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||||
f"register manually with: "
|
f"register manually with: "
|
||||||
f"codex mcp add --transport http supervise {supervise_url}"
|
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def headless_prompt(self, prompt: str) -> list[str]:
|
||||||
|
return [prompt]
|
||||||
|
|
||||||
|
|
||||||
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||||
result = bottle.exec(script, user="root")
|
result = bottle.exec(script, user="root")
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||||
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
||||||
only stdlib `urllib.request` and `subprocess`."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,7 +19,12 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...deploy_key_provisioner import DeployKeyProvisioner
|
from ...deploy_key_provisioner import DeployKeyCollisionError, DeployKeyProvisioner
|
||||||
|
|
||||||
|
# Timeout for ssh-keygen and Gitea API HTTP calls. A hung Gitea instance at
|
||||||
|
# prepare time would stall bottle launch indefinitely without this bound.
|
||||||
|
_API_TIMEOUT_SECS = 30
|
||||||
|
_KEYGEN_TIMEOUT_SECS = 10
|
||||||
|
|
||||||
|
|
||||||
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||||
@@ -40,6 +51,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
|||||||
check=True,
|
check=True,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=_KEYGEN_TIMEOUT_SECS,
|
||||||
)
|
)
|
||||||
private_key = key_path.read_bytes()
|
private_key = key_path.read_bytes()
|
||||||
public_key = key_path.with_suffix(".pub").read_text().strip()
|
public_key = key_path.with_suffix(".pub").read_text().strip()
|
||||||
@@ -61,10 +73,15 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
|||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS) as resp:
|
||||||
body = json.loads(resp.read())
|
body = json.loads(resp.read())
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
_body = _read_error_body(exc)
|
_body = _read_error_body(exc)
|
||||||
|
if exc.code == 422:
|
||||||
|
raise DeployKeyCollisionError(
|
||||||
|
f"deploy key collision for {owner_repo!r} "
|
||||||
|
f"(title={title!r}): key title or content already registered — {_body}"
|
||||||
|
) from exc
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"failed to create deploy key for {owner_repo}: "
|
f"failed to create deploy key for {owner_repo}: "
|
||||||
f"HTTP {exc.code} — {_body}"
|
f"HTTP {exc.code} — {_body}"
|
||||||
@@ -87,7 +104,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
|||||||
method="DELETE",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req):
|
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS):
|
||||||
pass
|
pass
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
if exc.code == 404:
|
if exc.code == 404:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pi agent provider package."""
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""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,
|
||||||
|
provider_startup_args,
|
||||||
|
)
|
||||||
|
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=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
extra_startup_args = provider_startup_args(provider_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),
|
||||||
|
*extra_startup_args,
|
||||||
|
),
|
||||||
|
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 {shlex.quote(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}")
|
||||||
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
|
bottle.exec(f"chown -R node:node {dst_q}", 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 headless_prompt(self, prompt: str) -> list[str]:
|
||||||
|
return ["-p", prompt]
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(bottle: "Bottle", script: str, error: str) -> None:
|
||||||
|
result = bottle.exec(script, user="root")
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
if detail:
|
||||||
|
detail = f": {detail}"
|
||||||
|
die(f"agent provider provisioning: {error}{detail}")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Shared SQLite-backed store base class for bot-bottle (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .migrations import TableMigrations
|
||||||
|
except ImportError:
|
||||||
|
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
|
class DbVersionError(Exception):
|
||||||
|
"""Raised when the on-disk schema is behind the current migration list."""
|
||||||
|
|
||||||
|
|
||||||
|
class DbStore:
|
||||||
|
"""Base for SQLite-backed stores. Subclasses resolve db_path then call super().__init__."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
self._migrations = migrations
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def is_migrated(self) -> bool:
|
||||||
|
"""Return True if the DB is fully up-to-date, False if migration is needed."""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT version FROM schema_versions WHERE module = ?",
|
||||||
|
(self._migrations.schema_key,),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return False
|
||||||
|
version = row[0] if row else 0
|
||||||
|
return version == len(self._migrations.migrations)
|
||||||
|
|
||||||
|
def migrate(self) -> None:
|
||||||
|
"""Apply any pending migrations and set permissions on the DB file."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
self._migrations.apply(conn)
|
||||||
|
self._chmod()
|
||||||
|
|
||||||
|
def _chmod(self) -> None:
|
||||||
|
try:
|
||||||
|
self.db_path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["DbStore", "DbVersionError"]
|
||||||
@@ -11,6 +11,10 @@ from __future__ import annotations
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class DeployKeyCollisionError(RuntimeError):
|
||||||
|
"""Raised when a deploy key title or public key already exists on the repo."""
|
||||||
|
|
||||||
|
|
||||||
class DeployKeyProvisioner(ABC):
|
class DeployKeyProvisioner(ABC):
|
||||||
"""Manages a single deploy-key lifecycle on a remote forge."""
|
"""Manages a single deploy-key lifecycle on a remote forge."""
|
||||||
|
|
||||||
|
|||||||
+262
-18
@@ -11,10 +11,13 @@ the same try/except import shim pattern.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import functools
|
||||||
import gzip
|
import gzip
|
||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from math import log2
|
||||||
|
from collections import Counter
|
||||||
from urllib.parse import quote as url_quote
|
from urllib.parse import quote as url_quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,16 +81,27 @@ TOKEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def scan_token_patterns(text: str, *, location: str = "body") -> ScanResult | None:
|
def scan_token_patterns(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
location: str = "body",
|
||||||
|
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||||
|
) -> ScanResult | None:
|
||||||
normalized = _normalize_text(text)
|
normalized = _normalize_text(text)
|
||||||
for name, pattern in TOKEN_PATTERNS:
|
for name, pattern in TOKEN_PATTERNS:
|
||||||
m = pattern.search(normalized)
|
for m in pattern.finditer(normalized):
|
||||||
if m is not None:
|
value = m.group(0)
|
||||||
|
# A value the supervisor has approved (PRD 0062) is no longer a
|
||||||
|
# block — keep scanning so a second, un-approved token in the
|
||||||
|
# same request is still caught.
|
||||||
|
if safe_tokens is not None and value in safe_tokens:
|
||||||
|
continue
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
severity="block",
|
severity="block",
|
||||||
reason=f"{name} found in {location}",
|
reason=f"{name} found in {location}",
|
||||||
location=location,
|
location=location,
|
||||||
context=_snippet(text, m.start(), m.end()),
|
context=_snippet(normalized, m.start(), m.end()),
|
||||||
|
matched=value,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -96,24 +110,46 @@ def redact_tokens(
|
|||||||
text: str,
|
text: str,
|
||||||
*,
|
*,
|
||||||
env: typing.Mapping[str, str] | None = None,
|
env: typing.Mapping[str, str] | None = None,
|
||||||
|
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Replace token pattern matches and (if env given) provisioned secrets with REDACT."""
|
"""Replace token pattern matches and (if env given) provisioned secrets with REDACT."""
|
||||||
for _, pattern in TOKEN_PATTERNS:
|
for _, pattern in TOKEN_PATTERNS:
|
||||||
text = pattern.sub(REDACT, text)
|
text = pattern.sub(REDACT, text)
|
||||||
if env is not None:
|
if env is not None:
|
||||||
for key, value in env.items():
|
for key, value in env.items():
|
||||||
if key.startswith("EGRESS_TOKEN_") and value:
|
if any(key.startswith(p) for p in sensitive_prefixes) and value:
|
||||||
for variant in _encoded_variants(value):
|
for variant in _encoded_variants(value):
|
||||||
text = text.replace(variant, REDACT)
|
text = text.replace(variant, REDACT)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Known secrets detector (Phase 1b)
|
# Known secrets detector
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Encoded-variant cache. Provisioned secrets are stable for the life of the
|
||||||
|
# proxy, but `_encoded_variants` is on the per-request hot path — it runs for
|
||||||
|
# every secret on every redaction and known-secret scan (host, path, each
|
||||||
|
# header, body). Deriving the variant set is relatively expensive (gzip +
|
||||||
|
# nine encodings), so memoize it per distinct secret. The proxy process
|
||||||
|
# already holds these values in `os.environ`, so caching them here adds no
|
||||||
|
# new exposure. The cache is bounded (lru_cache maxsize) so a long-lived
|
||||||
|
# proxy that sees rotating secrets evicts the oldest rather than growing
|
||||||
|
# without limit; 256 comfortably covers the EGRESS_TOKEN_* set in practice.
|
||||||
|
_VARIANT_CACHE_MAXSIZE = 256
|
||||||
|
|
||||||
|
|
||||||
def _encoded_variants(secret: str) -> list[str]:
|
def _encoded_variants(secret: str) -> list[str]:
|
||||||
"""Return the secret plus common encoded variants for exfil detection."""
|
"""Return the secret plus common encoded variants for exfil detection.
|
||||||
|
|
||||||
|
The variant set is computed once per distinct secret and cached; callers
|
||||||
|
get a fresh list so they can't mutate the shared cached tuple."""
|
||||||
|
return list(_compute_encoded_variants(secret))
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=_VARIANT_CACHE_MAXSIZE)
|
||||||
|
def _compute_encoded_variants(secret: str) -> tuple[str, ...]:
|
||||||
|
"""Derive the secret plus its encoded variants (memoized, bounded)."""
|
||||||
seen: set[str] = {secret}
|
seen: set[str] = {secret}
|
||||||
variants: list[str] = [secret]
|
variants: list[str] = [secret]
|
||||||
|
|
||||||
@@ -147,7 +183,52 @@ def _encoded_variants(secret: str) -> list[str]:
|
|||||||
# gzip + base64 (deterministic: mtime=0); recognisable by H4sI prefix
|
# gzip + base64 (deterministic: mtime=0); recognisable by H4sI prefix
|
||||||
_add(base64.b64encode(gzip.compress(secret_bytes, mtime=0)).decode("ascii"))
|
_add(base64.b64encode(gzip.compress(secret_bytes, mtime=0)).decode("ascii"))
|
||||||
|
|
||||||
return variants
|
return tuple(variants)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fragmentation-resistant helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Minimum length of alnum projection for projection-based checks to run.
|
||||||
|
# Short secrets produce too many false positives in projection space.
|
||||||
|
_ALNUM_MIN_LEN = 8
|
||||||
|
|
||||||
|
# Minimum window length for the partial-substring sliding scan.
|
||||||
|
PARTIAL_MATCH_MIN_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _alnum_projection(text: str) -> str:
|
||||||
|
"""Return text with every non-alphanumeric character stripped.
|
||||||
|
|
||||||
|
Used for fragmentation-resistant matching: separator-injected secrets
|
||||||
|
(spaces, hyphens, dots inserted between characters) are identical to
|
||||||
|
their originals in alnum projection space.
|
||||||
|
"""
|
||||||
|
return "".join(c for c in text if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def _find_partial_window(secret_alnum: str, text_alnum: str, min_len: int) -> int | None:
|
||||||
|
"""Return the earliest position in text_alnum holding a min_len-char window
|
||||||
|
that also appears in secret_alnum, or None.
|
||||||
|
|
||||||
|
The secret's set of min_len-grams is small (bounded by the secret length),
|
||||||
|
so building it once and sweeping the text a single time is O(len(text))
|
||||||
|
rather than the O(len(secret) * len(text)) of repeated substring searches —
|
||||||
|
which matters because this runs per provisioned secret on every request
|
||||||
|
body. Coverage is unchanged: a hit still means at least min_len consecutive
|
||||||
|
alphanumeric characters of the secret leaked into the text.
|
||||||
|
"""
|
||||||
|
if len(secret_alnum) < min_len or len(text_alnum) < min_len:
|
||||||
|
return None
|
||||||
|
secret_grams = {
|
||||||
|
secret_alnum[i:i + min_len]
|
||||||
|
for i in range(len(secret_alnum) - min_len + 1)
|
||||||
|
}
|
||||||
|
for pos in range(len(text_alnum) - min_len + 1):
|
||||||
|
if text_alnum[pos:pos + min_len] in secret_grams:
|
||||||
|
return pos
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def scan_known_secrets(
|
def scan_known_secrets(
|
||||||
@@ -155,21 +236,135 @@ def scan_known_secrets(
|
|||||||
*,
|
*,
|
||||||
location: str = "body",
|
location: str = "body",
|
||||||
env: typing.Mapping[str, str] | None = None,
|
env: typing.Mapping[str, str] | None = None,
|
||||||
|
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||||
|
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||||
) -> ScanResult | None:
|
) -> ScanResult | None:
|
||||||
if env is None:
|
if env is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Pre-compute alnum projection of the scan text once; reused per secret.
|
||||||
|
text_alnum: str | None = None
|
||||||
|
|
||||||
for key, value in env.items():
|
for key, value in env.items():
|
||||||
if not key.startswith("EGRESS_TOKEN_") or not value:
|
if not any(key.startswith(p) for p in sensitive_prefixes) or not value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Pass 1: exact match across encoded variants (original behaviour).
|
||||||
|
approved_exact = False
|
||||||
for variant in _encoded_variants(value):
|
for variant in _encoded_variants(value):
|
||||||
pos = text.find(variant)
|
pos = text.find(variant)
|
||||||
if pos >= 0:
|
if pos >= 0:
|
||||||
|
# The supervisor approves the exact encoded variant found
|
||||||
|
# (PRD 0062); a different encoding of the same secret is a
|
||||||
|
# fresh block.
|
||||||
|
if safe_tokens is not None and variant in safe_tokens:
|
||||||
|
approved_exact = True
|
||||||
|
continue
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
severity="block",
|
severity="block",
|
||||||
reason=f"provisioned secret from {key} found in {location}",
|
reason=f"provisioned secret from {key} found in {location}",
|
||||||
location=location,
|
location=location,
|
||||||
context=_snippet(text, pos, pos + len(variant)),
|
context=_snippet(text, pos, pos + len(variant)),
|
||||||
|
matched=variant,
|
||||||
)
|
)
|
||||||
|
if approved_exact:
|
||||||
|
# Exact match was found and approved; projection passes would
|
||||||
|
# fire on the same value, so skip them for this secret.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pass 2 & 3: fragmentation-resistant projection checks.
|
||||||
|
secret_alnum = _alnum_projection(value)
|
||||||
|
if len(secret_alnum) < _ALNUM_MIN_LEN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if text_alnum is None:
|
||||||
|
text_alnum = _alnum_projection(text)
|
||||||
|
|
||||||
|
# Pass 2: full alnum-projection exact match (catches separator injection).
|
||||||
|
pos2 = text_alnum.find(secret_alnum)
|
||||||
|
if pos2 >= 0:
|
||||||
|
return ScanResult(
|
||||||
|
severity="block",
|
||||||
|
reason=(
|
||||||
|
f"provisioned secret from {key} found in {location} "
|
||||||
|
f"(fragmented match — separator injection)"
|
||||||
|
),
|
||||||
|
location=location,
|
||||||
|
context=_snippet(text_alnum, pos2, pos2 + len(secret_alnum)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass 3: sliding-window partial match (catches chunked-substring leaks).
|
||||||
|
pos3 = _find_partial_window(secret_alnum, text_alnum, PARTIAL_MATCH_MIN_LEN)
|
||||||
|
if pos3 is not None:
|
||||||
|
return ScanResult(
|
||||||
|
severity="block",
|
||||||
|
reason=(
|
||||||
|
f"provisioned secret from {key} found in {location} "
|
||||||
|
f"(partial match — at least {PARTIAL_MATCH_MIN_LEN} consecutive "
|
||||||
|
f"alphanumeric chars)"
|
||||||
|
),
|
||||||
|
location=location,
|
||||||
|
context=_snippet(text_alnum, pos3, pos3 + PARTIAL_MATCH_MIN_LEN),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entropy detector (warn-only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Sliding window size and step for the entropy scan.
|
||||||
|
ENTROPY_WINDOW = 64
|
||||||
|
ENTROPY_STEP = 32
|
||||||
|
|
||||||
|
# Bits-per-character threshold. Random ASCII printable ≈ 6.6 bits; random
|
||||||
|
# lowercase hex ≈ 4 bits; random base64url ≈ 6 bits. 5.5 sits above
|
||||||
|
# typical structured data (JSON, URLs) while staying below truly random
|
||||||
|
# content.
|
||||||
|
ENTROPY_BLOCK_THRESHOLD = 5.5
|
||||||
|
|
||||||
|
|
||||||
|
def _shannon_entropy(text: str) -> float:
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
counts = Counter(text)
|
||||||
|
n = len(text)
|
||||||
|
return -sum((c / n) * log2(c / n) for c in counts.values())
|
||||||
|
|
||||||
|
|
||||||
|
def scan_entropy(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
location: str = "body",
|
||||||
|
window: int = ENTROPY_WINDOW,
|
||||||
|
threshold: float = ENTROPY_BLOCK_THRESHOLD,
|
||||||
|
) -> ScanResult | None:
|
||||||
|
"""Warn-only detector: flag windows of `window` chars with Shannon entropy
|
||||||
|
above `threshold` bits per character.
|
||||||
|
|
||||||
|
Never blocks; always returns severity='warn'. Disabled by default —
|
||||||
|
routes must opt in via dlp.outbound_detectors=['entropy'].
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
step = max(1, window // 2)
|
||||||
|
end = len(text)
|
||||||
|
# Scan overlapping windows; also check the final tail if shorter than window.
|
||||||
|
positions = list(range(0, end - window + 1, step))
|
||||||
|
if end < window:
|
||||||
|
positions = [0]
|
||||||
|
elif (end - window) % step != 0:
|
||||||
|
positions.append(end - window)
|
||||||
|
for i in positions:
|
||||||
|
chunk = text[i:i + window]
|
||||||
|
if _shannon_entropy(chunk) >= threshold:
|
||||||
|
return ScanResult(
|
||||||
|
severity="warn",
|
||||||
|
reason=f"high-entropy content in {location} (possible encrypted exfil)",
|
||||||
|
location=location,
|
||||||
|
context=_snippet(text, i, i + len(chunk)),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -197,19 +392,52 @@ JAILBREAK_PHRASES: tuple[re.Pattern[str], ...] = (
|
|||||||
PROXIMITY_CHARS = 500
|
PROXIMITY_CHARS = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _match_gap(a: re.Match[str], b: re.Match[str]) -> int:
|
||||||
|
"""Character gap between two match spans; 0 when they overlap or touch."""
|
||||||
|
return max(0, max(a.start(), b.start()) - min(a.end(), b.end()))
|
||||||
|
|
||||||
|
|
||||||
def _closest_pair(
|
def _closest_pair(
|
||||||
a_matches: list[re.Match[str]],
|
a_matches: list[re.Match[str]],
|
||||||
b_matches: list[re.Match[str]],
|
b_matches: list[re.Match[str]],
|
||||||
|
*,
|
||||||
|
within: int | None = None,
|
||||||
) -> tuple[re.Match[str], re.Match[str]] | None:
|
) -> tuple[re.Match[str], re.Match[str]] | None:
|
||||||
"""Return the pair (a, b) with the smallest character gap, or None."""
|
"""Return the (a, b) pair with the smallest character gap, or None when
|
||||||
|
either list is empty.
|
||||||
|
|
||||||
|
Runs in O(n log n) sort + O(n) merge rather than the O(n*m) cross product:
|
||||||
|
both lists are sorted by start offset and swept with a two-pointer merge,
|
||||||
|
advancing whichever span ends first (it can only get farther from any
|
||||||
|
later span in the other list). This matters because the inputs are
|
||||||
|
attacker-controlled response-body matches that have already passed the
|
||||||
|
body-size cap, so the quadratic form is a latent DoS.
|
||||||
|
|
||||||
|
When `within` is set, returns as soon as a pair with gap <= within is
|
||||||
|
found: the only caller blocks on any pair inside the proximity threshold,
|
||||||
|
so the exact global minimum past that point doesn't change the decision.
|
||||||
|
"""
|
||||||
|
if not a_matches or not b_matches:
|
||||||
|
return None
|
||||||
|
a_sorted = sorted(a_matches, key=lambda m: m.start())
|
||||||
|
b_sorted = sorted(b_matches, key=lambda m: m.start())
|
||||||
|
i = j = 0
|
||||||
best: tuple[re.Match[str], re.Match[str]] | None = None
|
best: tuple[re.Match[str], re.Match[str]] | None = None
|
||||||
best_gap: int | None = None
|
best_gap: int | None = None
|
||||||
for a in a_matches:
|
while i < len(a_sorted) and j < len(b_sorted):
|
||||||
for b in b_matches:
|
a, b = a_sorted[i], b_sorted[j]
|
||||||
gap = max(0, max(a.start(), b.start()) - min(a.end(), b.end()))
|
gap = _match_gap(a, b)
|
||||||
if best_gap is None or gap < best_gap:
|
if best_gap is None or gap < best_gap:
|
||||||
best_gap = gap
|
best_gap = gap
|
||||||
best = (a, b)
|
best = (a, b)
|
||||||
|
if within is not None and gap <= within:
|
||||||
|
return best
|
||||||
|
# Advance the span that ends first; it cannot form a closer pair with
|
||||||
|
# any later (further-right) span from the other list.
|
||||||
|
if a.end() <= b.end():
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
j += 1
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
@@ -219,9 +447,9 @@ def scan_naive_injection(text: str) -> ScanResult | None:
|
|||||||
jailbreak_hits = [m for p in JAILBREAK_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:
|
if disclosure_hits and jailbreak_hits:
|
||||||
pair = _closest_pair(disclosure_hits, jailbreak_hits)
|
pair = _closest_pair(disclosure_hits, jailbreak_hits, within=PROXIMITY_CHARS)
|
||||||
if pair is not None:
|
if pair is not None:
|
||||||
dist = max(0, max(pair[0].start(), pair[1].start()) - min(pair[0].end(), pair[1].end()))
|
dist = _match_gap(pair[0], pair[1])
|
||||||
if dist <= PROXIMITY_CHARS:
|
if dist <= PROXIMITY_CHARS:
|
||||||
first = pair[0] if pair[0].start() <= pair[1].start() else pair[1]
|
first = pair[0] if pair[0].start() <= pair[1].start() else pair[1]
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
@@ -265,6 +493,14 @@ _CRLF_ENCODED_RE = re.compile(r"%0[dD]%0[aA]", re.ASCII)
|
|||||||
_CRLF_HEADER_INJECT_RE = re.compile(r"\r\n[A-Za-z][A-Za-z0-9\-]+\s*:", re.ASCII)
|
_CRLF_HEADER_INJECT_RE = re.compile(r"\r\n[A-Za-z][A-Za-z0-9\-]+\s*:", re.ASCII)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_crlf(text: str) -> str:
|
||||||
|
"""Remove URL-encoded and literal CRLF injection sequences from a request
|
||||||
|
surface (PRD 0062 redact policy). Used to scrub the request line / headers
|
||||||
|
so the request can be forwarded instead of hard-blocked."""
|
||||||
|
text = _CRLF_ENCODED_RE.sub("", text)
|
||||||
|
return _CRLF_HEADER_INJECT_RE.sub(lambda m: m.group(0)[2:], text)
|
||||||
|
|
||||||
|
|
||||||
def scan_crlf_injection(text: str) -> ScanResult | None:
|
def scan_crlf_injection(text: str) -> ScanResult | None:
|
||||||
if _CRLF_ENCODED_RE.search(text):
|
if _CRLF_ENCODED_RE.search(text):
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
@@ -280,12 +516,20 @@ def scan_crlf_injection(text: str) -> ScanResult | None:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ENTROPY_BLOCK_THRESHOLD",
|
||||||
|
"ENTROPY_WINDOW",
|
||||||
|
"ENTROPY_STEP",
|
||||||
|
"PARTIAL_MATCH_MIN_LEN",
|
||||||
"REDACT",
|
"REDACT",
|
||||||
"SNIPPET_CONTEXT",
|
"SNIPPET_CONTEXT",
|
||||||
"TOKEN_PATTERNS",
|
"TOKEN_PATTERNS",
|
||||||
|
"_alnum_projection",
|
||||||
|
"_shannon_entropy",
|
||||||
"redact_tokens",
|
"redact_tokens",
|
||||||
"scan_crlf_injection",
|
"scan_crlf_injection",
|
||||||
|
"scan_entropy",
|
||||||
"scan_known_secrets",
|
"scan_known_secrets",
|
||||||
"scan_naive_injection",
|
"scan_naive_injection",
|
||||||
"scan_token_patterns",
|
"scan_token_patterns",
|
||||||
|
"strip_crlf",
|
||||||
]
|
]
|
||||||
|
|||||||
+120
-16
@@ -10,17 +10,20 @@ specific and lives on concrete subclasses (see
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import secrets
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .egress_addon_core import (
|
from .egress_addon_core import (
|
||||||
|
ON_MATCH_REDACT,
|
||||||
HeaderMatch as CoreHeaderMatch,
|
HeaderMatch as CoreHeaderMatch,
|
||||||
MatchEntry as CoreMatchEntry,
|
MatchEntry as CoreMatchEntry,
|
||||||
PathMatch as CorePathMatch,
|
PathMatch as CorePathMatch,
|
||||||
Route,
|
Route,
|
||||||
)
|
)
|
||||||
|
from .errors import MissingEnvVarError
|
||||||
from .log import die
|
from .log import die
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -31,6 +34,51 @@ CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
|||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
||||||
|
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
|
||||||
|
|
||||||
|
_CANARY_ENV_WORDS = (
|
||||||
|
"ACCORD",
|
||||||
|
"ANCHOR",
|
||||||
|
"ATLAS",
|
||||||
|
"CANON",
|
||||||
|
"CIPHER",
|
||||||
|
"EMBER",
|
||||||
|
"FALCON",
|
||||||
|
"HARBOR",
|
||||||
|
"LANTERN",
|
||||||
|
"MARBLE",
|
||||||
|
"NOVA",
|
||||||
|
"ORBIT",
|
||||||
|
"PIVOT",
|
||||||
|
"RADIUS",
|
||||||
|
"SUMMIT",
|
||||||
|
"VECTOR",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_canary_env() -> str:
|
||||||
|
first = secrets.choice(_CANARY_ENV_WORDS)
|
||||||
|
remaining = tuple(word for word in _CANARY_ENV_WORDS if word != first)
|
||||||
|
second = secrets.choice(remaining)
|
||||||
|
return f"{first}_{second}_SECRET"
|
||||||
|
|
||||||
|
|
||||||
|
def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||||
|
"""Return sidecar env entries needed by egress across all backends."""
|
||||||
|
env: list[str] = []
|
||||||
|
if plan.routes:
|
||||||
|
env.extend(sorted(plan.token_env_map.keys()))
|
||||||
|
if plan.canary and plan.canary_env:
|
||||||
|
env.append(f"{plan.canary_env}={plan.canary}")
|
||||||
|
env.append(f"BOT_BOTTLE_SENSITIVE_PREFIXES={plan.canary_env}")
|
||||||
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
|
def egress_agent_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||||
|
"""Return agent-visible egress env entries shared by all backends."""
|
||||||
|
if plan.canary and plan.canary_env:
|
||||||
|
return (f"{plan.canary_env}={plan.canary}",)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -63,6 +111,8 @@ class EgressPlan:
|
|||||||
mitmproxy_ca_host_path: Path = Path()
|
mitmproxy_ca_host_path: Path = Path()
|
||||||
mitmproxy_ca_cert_only_host_path: Path = Path()
|
mitmproxy_ca_cert_only_host_path: Path = Path()
|
||||||
log: int = 0
|
log: int = 0
|
||||||
|
canary: str = ""
|
||||||
|
canary_env: str = ""
|
||||||
|
|
||||||
|
|
||||||
def egress_manifest_routes(
|
def egress_manifest_routes(
|
||||||
@@ -91,8 +141,10 @@ def egress_manifest_routes(
|
|||||||
auth_scheme=r.AuthScheme,
|
auth_scheme=r.AuthScheme,
|
||||||
token_ref=r.TokenRef,
|
token_ref=r.TokenRef,
|
||||||
roles=r.Role,
|
roles=r.Role,
|
||||||
|
git_fetch=r.GitFetch,
|
||||||
outbound_detectors=r.OutboundDetectors,
|
outbound_detectors=r.OutboundDetectors,
|
||||||
inbound_detectors=r.InboundDetectors,
|
inbound_detectors=r.InboundDetectors,
|
||||||
|
outbound_on_match=r.OutboundOnMatch,
|
||||||
))
|
))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
@@ -103,12 +155,27 @@ def egress_routes_for_bottle(
|
|||||||
) -> tuple[EgressRoute, ...]:
|
) -> tuple[EgressRoute, ...]:
|
||||||
manifest = egress_manifest_routes(bottle)
|
manifest = egress_manifest_routes(bottle)
|
||||||
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
||||||
merged = list(provider_routes) + [
|
merged = list(_default_provider_on_match(provider_routes)) + [
|
||||||
r for r in manifest if r.host.lower() not in provisioned_hosts
|
r for r in manifest if r.host.lower() not in provisioned_hosts
|
||||||
]
|
]
|
||||||
return _assign_token_slots(merged)
|
return _assign_token_slots(merged)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_provider_on_match(
|
||||||
|
provider_routes: tuple[EgressRoute, ...],
|
||||||
|
) -> tuple[EgressRoute, ...]:
|
||||||
|
"""Provider routes (the agent talking to its own LLM API) default to the
|
||||||
|
`redact` on-match policy (PRD 0062): high-volume conversation payloads are
|
||||||
|
the worst source of token-shaped false positives, so a match is scrubbed
|
||||||
|
and forwarded rather than hard-blocked or queued for the operator. A
|
||||||
|
provider that sets `outbound_on_match` explicitly keeps its choice."""
|
||||||
|
return tuple(
|
||||||
|
r if r.outbound_on_match
|
||||||
|
else dataclasses.replace(r, outbound_on_match=ON_MATCH_REDACT)
|
||||||
|
for r in provider_routes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assign_token_slots(
|
def _assign_token_slots(
|
||||||
routes: list[EgressRoute],
|
routes: list[EgressRoute],
|
||||||
) -> tuple[EgressRoute, ...]:
|
) -> tuple[EgressRoute, ...]:
|
||||||
@@ -144,6 +211,17 @@ def egress_token_env_map(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _yaml_str_escape(s: str) -> str:
|
||||||
|
"""Escape a string for use inside a YAML double-quoted scalar."""
|
||||||
|
return (
|
||||||
|
s.replace("\\", "\\\\")
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||||
fields: dict[str, object] = {"host": r.host}
|
fields: dict[str, object] = {"host": r.host}
|
||||||
if r.auth_scheme and r.token_env:
|
if r.auth_scheme and r.token_env:
|
||||||
@@ -173,7 +251,13 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
|||||||
entry_data["headers"] = headers_data
|
entry_data["headers"] = headers_data
|
||||||
matches_data.append(entry_data)
|
matches_data.append(entry_data)
|
||||||
fields["matches"] = matches_data
|
fields["matches"] = matches_data
|
||||||
if r.outbound_detectors is not None or r.inbound_detectors is not None:
|
if r.git_fetch:
|
||||||
|
fields["git"] = {"fetch": True}
|
||||||
|
if (
|
||||||
|
r.outbound_detectors is not None
|
||||||
|
or r.inbound_detectors is not None
|
||||||
|
or r.outbound_on_match
|
||||||
|
):
|
||||||
dlp: dict[str, object] = {}
|
dlp: dict[str, object] = {}
|
||||||
if r.outbound_detectors is not None:
|
if r.outbound_detectors is not None:
|
||||||
dlp["outbound_detectors"] = (
|
dlp["outbound_detectors"] = (
|
||||||
@@ -185,6 +269,8 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
|||||||
False if not r.inbound_detectors
|
False if not r.inbound_detectors
|
||||||
else list(r.inbound_detectors)
|
else list(r.inbound_detectors)
|
||||||
)
|
)
|
||||||
|
if r.outbound_on_match:
|
||||||
|
dlp["outbound_on_match"] = r.outbound_on_match
|
||||||
fields["dlp"] = dlp
|
fields["dlp"] = dlp
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
@@ -198,12 +284,12 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
|||||||
for pd in entry["paths"]: # type: ignore[union-attr]
|
for pd in entry["paths"]: # type: ignore[union-attr]
|
||||||
pd_dict: dict[str, str] = pd # type: ignore[assignment]
|
pd_dict: dict[str, str] = pd # type: ignore[assignment]
|
||||||
if "type" in pd_dict:
|
if "type" in pd_dict:
|
||||||
lines.append(f' - type: "{pd_dict["type"]}"')
|
lines.append(f' - type: "{_yaml_str_escape(pd_dict["type"])}"')
|
||||||
lines.append(f' value: "{pd_dict["value"]}"')
|
lines.append(f' value: "{_yaml_str_escape(pd_dict["value"])}"')
|
||||||
else:
|
else:
|
||||||
lines.append(f' - value: "{pd_dict["value"]}"')
|
lines.append(f' - value: "{_yaml_str_escape(pd_dict["value"])}"')
|
||||||
if "methods" in entry:
|
if "methods" in entry:
|
||||||
methods_str = ", ".join(f'"{m}"' for m in entry["methods"]) # type: ignore[union-attr]
|
methods_str = ", ".join(f'"{_yaml_str_escape(m)}"' for m in entry["methods"]) # type: ignore[union-attr]
|
||||||
prefix = " - " if first_key else " "
|
prefix = " - " if first_key else " "
|
||||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
lines.append(f'{prefix}methods: [{methods_str}]')
|
||||||
first_key = False
|
first_key = False
|
||||||
@@ -213,8 +299,8 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
|||||||
first_key = False
|
first_key = False
|
||||||
for hd in entry["headers"]: # type: ignore[union-attr]
|
for hd in entry["headers"]: # type: ignore[union-attr]
|
||||||
hd_dict: dict[str, str] = hd # type: ignore[assignment]
|
hd_dict: dict[str, str] = hd # type: ignore[assignment]
|
||||||
lines.append(f' - name: "{hd_dict["name"]}"')
|
lines.append(f' - name: "{_yaml_str_escape(hd_dict["name"])}"')
|
||||||
lines.append(f' value: "{hd_dict["value"]}"')
|
lines.append(f' value: "{_yaml_str_escape(hd_dict["value"])}"')
|
||||||
if first_key:
|
if first_key:
|
||||||
lines.append(" - {}")
|
lines.append(" - {}")
|
||||||
return lines
|
return lines
|
||||||
@@ -234,14 +320,19 @@ def egress_render_routes(
|
|||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
for r in routes:
|
for r in routes:
|
||||||
f = _route_to_yaml_fields(r)
|
f = _route_to_yaml_fields(r)
|
||||||
lines.append(f' - host: "{f["host"]}"')
|
lines.append(f' - host: "{_yaml_str_escape(str(f["host"]))}"')
|
||||||
if "auth_scheme" in f:
|
if "auth_scheme" in f:
|
||||||
lines.append(f' auth_scheme: "{f["auth_scheme"]}"')
|
lines.append(f' auth_scheme: "{_yaml_str_escape(str(f["auth_scheme"]))}"')
|
||||||
lines.append(f' token_env: "{f["token_env"]}"')
|
lines.append(f' token_env: "{_yaml_str_escape(str(f["token_env"]))}"')
|
||||||
if "matches" in f:
|
if "matches" in f:
|
||||||
lines.append(" matches:")
|
lines.append(" matches:")
|
||||||
for entry in f["matches"]: # type: ignore[union-attr]
|
for entry in f["matches"]: # type: ignore[union-attr]
|
||||||
lines.extend(_render_match_entry(entry)) # type: ignore[arg-type]
|
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:
|
if "dlp" in f:
|
||||||
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
||||||
lines.append(" dlp:")
|
lines.append(" dlp:")
|
||||||
@@ -251,6 +342,8 @@ def egress_render_routes(
|
|||||||
elif isinstance(dv, list):
|
elif isinstance(dv, list):
|
||||||
items_str = ", ".join(f'"{x}"' for x in dv)
|
items_str = ", ".join(f'"{x}"' for x in dv)
|
||||||
lines.append(f" {dk}: [{items_str}]")
|
lines.append(f" {dk}: [{items_str}]")
|
||||||
|
elif isinstance(dv, str):
|
||||||
|
lines.append(f' {dk}: "{_yaml_str_escape(dv)}"')
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -262,16 +355,18 @@ def egress_resolve_token_values(
|
|||||||
for token_env, token_ref in token_env_map.items():
|
for token_env, token_ref in token_env_map.items():
|
||||||
value = host_env.get(token_ref)
|
value = host_env.get(token_ref)
|
||||||
if value is None:
|
if value is None:
|
||||||
die(
|
raise MissingEnvVarError(
|
||||||
|
token_ref,
|
||||||
f"egress: host env var '{token_ref}' is unset. Set it "
|
f"egress: host env var '{token_ref}' is unset. Set it "
|
||||||
f"before launching, or remove the corresponding auth block "
|
f"before launching, or remove the corresponding auth block "
|
||||||
f"from bottle.egress.routes."
|
f"from bottle.egress.routes.",
|
||||||
)
|
)
|
||||||
if not value:
|
if not value:
|
||||||
die(
|
raise MissingEnvVarError(
|
||||||
|
token_ref,
|
||||||
f"egress: host env var '{token_ref}' is empty. The "
|
f"egress: host env var '{token_ref}' is empty. The "
|
||||||
f"egress will not inject an empty token; set it to "
|
f"egress will not inject an empty token; set it to "
|
||||||
f"the real value or remove the route's auth block."
|
f"the real value or remove the route's auth block.",
|
||||||
)
|
)
|
||||||
out[token_env] = value
|
out[token_env] = value
|
||||||
return out
|
return out
|
||||||
@@ -287,20 +382,27 @@ class Egress(ABC):
|
|||||||
) -> EgressPlan:
|
) -> EgressPlan:
|
||||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||||
log = bottle.egress.Log
|
log = bottle.egress.Log
|
||||||
routes_path = stage_dir / "egress_routes.yaml"
|
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
||||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||||
routes_path.chmod(0o600)
|
routes_path.chmod(0o600)
|
||||||
|
# Generate a per-session fake secret under a plausible random env name.
|
||||||
|
# The sidecar marks that exact env name as sensitive for known-secret
|
||||||
|
# scanning; the agent receives the same name/value as exfil bait.
|
||||||
|
canary = secrets.token_urlsafe(32)
|
||||||
return EgressPlan(
|
return EgressPlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
routes_path=routes_path,
|
routes_path=routes_path,
|
||||||
routes=routes,
|
routes=routes,
|
||||||
token_env_map=egress_token_env_map(routes),
|
token_env_map=egress_token_env_map(routes),
|
||||||
log=log,
|
log=log,
|
||||||
|
canary=canary,
|
||||||
|
canary_env=_random_canary_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
|
"EGRESS_ROUTES_FILENAME",
|
||||||
"EGRESS_ROUTES_IN_CONTAINER",
|
"EGRESS_ROUTES_IN_CONTAINER",
|
||||||
"Egress",
|
"Egress",
|
||||||
"EgressPlan",
|
"EgressPlan",
|
||||||
@@ -309,5 +411,7 @@ __all__ = [
|
|||||||
"egress_render_routes",
|
"egress_render_routes",
|
||||||
"egress_resolve_token_values",
|
"egress_resolve_token_values",
|
||||||
"egress_routes_for_bottle",
|
"egress_routes_for_bottle",
|
||||||
|
"egress_agent_env_entries",
|
||||||
|
"egress_sidecar_env_entries",
|
||||||
"egress_token_env_map",
|
"egress_token_env_map",
|
||||||
]
|
]
|
||||||
|
|||||||
+295
-24
@@ -5,7 +5,7 @@ egress container."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -17,36 +17,76 @@ from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=
|
|||||||
from egress_addon_core import ( # 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_BLOCKS,
|
||||||
LOG_FULL,
|
LOG_FULL,
|
||||||
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
|
ON_MATCH_BLOCK,
|
||||||
|
ON_MATCH_REDACT,
|
||||||
Config,
|
Config,
|
||||||
|
Route,
|
||||||
|
ScanResult,
|
||||||
build_inbound_scan_text,
|
build_inbound_scan_text,
|
||||||
build_outbound_scan_text,
|
build_outbound_scan_text,
|
||||||
|
build_token_allow_payload,
|
||||||
decide,
|
decide,
|
||||||
|
decide_git_fetch,
|
||||||
|
is_git_fetch_request,
|
||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
load_config,
|
load_config,
|
||||||
match_route,
|
match_route,
|
||||||
outbound_scan_headers,
|
outbound_scan_headers,
|
||||||
|
route_to_yaml_dict,
|
||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
||||||
except ImportError: # pragma: no cover - host-side path
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from bot_bottle.dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
||||||
|
redact_tokens,
|
||||||
|
strip_crlf,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import supervise as _sv # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
|
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
INTROSPECT_HOST = "_egress.local"
|
||||||
|
|
||||||
|
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
||||||
|
# operator's supervisor decision (PRD 0062), overridable via env.
|
||||||
|
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
|
||||||
|
# Filesystem poll cadence while awaiting the operator's response.
|
||||||
|
TOKEN_ALLOW_POLL_INTERVAL_SECONDS = 0.5
|
||||||
|
|
||||||
|
# Fixed operator guidance attached to every token-allow proposal.
|
||||||
|
_TOKEN_ALLOW_JUSTIFICATION = (
|
||||||
|
"egress DLP blocked an outbound request carrying a detected token. "
|
||||||
|
"Approve only if this value is a false positive or a credential this "
|
||||||
|
"request legitimately needs; the value is then allowed for the life of "
|
||||||
|
"this bottle's egress proxy."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EgressAddon:
|
class EgressAddon:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
||||||
self.config: Config = Config(routes=())
|
self.config: Config = Config(routes=())
|
||||||
|
# Tokens the operator has approved this session (PRD 0062). In-memory
|
||||||
|
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||||
|
# runs the addon hooks, so no lock is needed.
|
||||||
|
self.safe_tokens: set[str] = set()
|
||||||
|
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||||
|
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||||
self._reload(initial=True)
|
self._reload(initial=True)
|
||||||
self._install_sighup()
|
self._install_sighup()
|
||||||
|
|
||||||
|
def _supervise_available(self) -> bool:
|
||||||
|
return bool(self._supervise_slug)
|
||||||
|
|
||||||
def _reload(self, *, initial: bool = False) -> None:
|
def _reload(self, *, initial: bool = False) -> None:
|
||||||
try:
|
try:
|
||||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
text = Path(self.routes_path).read_text(encoding="utf-8")
|
||||||
@@ -80,7 +120,7 @@ class EgressAddon:
|
|||||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
||||||
if path == "/allowlist":
|
if path == "/allowlist":
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"routes": [dataclasses.asdict(r) for r in self.config.routes]},
|
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
||||||
indent=2,
|
indent=2,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
flow.response = http.Response.make(
|
flow.response = http.Response.make(
|
||||||
@@ -119,31 +159,42 @@ class EgressAddon:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
def _log_request(self, flow: http.HTTPFlow) -> None:
|
||||||
|
headers = {
|
||||||
|
k: redact_tokens(v, env=os.environ)
|
||||||
|
for k, v in flow.request.headers.items()
|
||||||
|
if k.lower() != "authorization"
|
||||||
|
}
|
||||||
|
body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "egress_request",
|
"event": "egress_request",
|
||||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
||||||
"method": flow.request.method,
|
"method": flow.request.method,
|
||||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
"path": redact_tokens(flow.request.path, env=os.environ),
|
||||||
"headers": dict(flow.request.headers),
|
"headers": headers,
|
||||||
"body": flow.request.get_text(strict=False) or "",
|
"body": body,
|
||||||
})
|
})
|
||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
def _log_response(self, flow: http.HTTPFlow) -> None:
|
||||||
|
headers = {
|
||||||
|
k: redact_tokens(v, env=os.environ)
|
||||||
|
for k, v in flow.response.headers.items()
|
||||||
|
}
|
||||||
|
body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "egress_response",
|
"event": "egress_response",
|
||||||
"host": flow.request.pretty_host,
|
"host": flow.request.pretty_host,
|
||||||
"status": flow.response.status_code,
|
"status": flow.response.status_code,
|
||||||
"headers": dict(flow.response.headers),
|
"headers": headers,
|
||||||
"body": flow.response.get_text(strict=False) or "",
|
"body": body,
|
||||||
})
|
})
|
||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def request(self, flow: http.HTTPFlow) -> None:
|
async def request(self, flow: http.HTTPFlow) -> None:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
if flow.request.pretty_host == INTROSPECT_HOST:
|
if flow.request.pretty_host == INTROSPECT_HOST:
|
||||||
@@ -155,21 +206,11 @@ class EgressAddon:
|
|||||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||||
if route is not None:
|
if route is not None:
|
||||||
body = flow.request.get_text(strict=False) or ""
|
if not await self._handle_outbound_dlp(flow, route):
|
||||||
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
|
return
|
||||||
|
# The redact policy may have rewritten the request line; recompute
|
||||||
|
# the path/query the git checks below rely on.
|
||||||
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
if is_git_push_request(request_path, query):
|
if is_git_push_request(request_path, query):
|
||||||
self._block(
|
self._block(
|
||||||
@@ -181,6 +222,18 @@ class EgressAddon:
|
|||||||
)
|
)
|
||||||
return
|
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
|
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
||||||
# are caught above; the route may inject sidecar-owned auth below.
|
# are caught above; the route may inject sidecar-owned auth below.
|
||||||
flow.request.headers.pop("authorization", None)
|
flow.request.headers.pop("authorization", None)
|
||||||
@@ -207,6 +260,200 @@ class EgressAddon:
|
|||||||
if self.config.log >= LOG_FULL:
|
if self.config.log >= LOG_FULL:
|
||||||
self._log_request(flow)
|
self._log_request(flow)
|
||||||
|
|
||||||
|
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||||
|
ctx = self._req_ctx(flow)
|
||||||
|
if result.context:
|
||||||
|
ctx = {**ctx, "context": result.context}
|
||||||
|
self._block(flow, f"egress DLP: {result.reason}", ctx=ctx)
|
||||||
|
|
||||||
|
async def _handle_outbound_dlp(
|
||||||
|
self,
|
||||||
|
flow: http.HTTPFlow,
|
||||||
|
route: Route,
|
||||||
|
) -> bool:
|
||||||
|
"""Scan the outbound request and apply the route's on-match policy
|
||||||
|
(PRD 0062). Returns True if the request may be forwarded, False if a
|
||||||
|
403 response has been written to `flow`.
|
||||||
|
|
||||||
|
Loops so the supervise policy can re-scan after each approval — a
|
||||||
|
second, un-approved token in the same request is still caught."""
|
||||||
|
while True:
|
||||||
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
body = flow.request.get_text(strict=False) or ""
|
||||||
|
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||||
|
scan_text = build_outbound_scan_text(
|
||||||
|
flow.request.pretty_host, request_path, query, headers, body,
|
||||||
|
)
|
||||||
|
# CRLF is scanned only over the request line + headers, never the
|
||||||
|
# body (see scan_outbound) — a body is not an injection vector.
|
||||||
|
crlf_text = build_outbound_scan_text(
|
||||||
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
|
)
|
||||||
|
result = scan_outbound(
|
||||||
|
route, scan_text, os.environ,
|
||||||
|
safe_tokens=self.safe_tokens, crlf_text=crlf_text,
|
||||||
|
)
|
||||||
|
if result is None or result.severity != "block":
|
||||||
|
return True
|
||||||
|
|
||||||
|
policy = route.outbound_on_match or DEFAULT_OUTBOUND_ON_MATCH
|
||||||
|
|
||||||
|
# redact scrubs every detection (tokens and structural CRLF) and
|
||||||
|
# forwards; it fails closed only if a match survives the scrub.
|
||||||
|
if policy == ON_MATCH_REDACT:
|
||||||
|
if self._redact_outbound(flow, route):
|
||||||
|
if self.config.log >= LOG_BLOCKS:
|
||||||
|
sys.stderr.write(json.dumps({
|
||||||
|
"event": "egress_redacted",
|
||||||
|
"reason": f"egress DLP: {result.reason}",
|
||||||
|
**self._req_ctx(flow),
|
||||||
|
}) + "\n")
|
||||||
|
return True
|
||||||
|
self._block(
|
||||||
|
flow,
|
||||||
|
f"egress DLP: {result.reason}; redaction could not remove "
|
||||||
|
"all matches (e.g. a match in the hostname)",
|
||||||
|
ctx=self._req_ctx(flow),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Structural blocks (CRLF, no safelist-able value) cannot be
|
||||||
|
# supervised — there is nothing to approve and remember — so under
|
||||||
|
# block/supervise they are a hard 403.
|
||||||
|
if policy == ON_MATCH_BLOCK or not result.matched:
|
||||||
|
self._block_dlp(flow, result)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# supervise (default): hold the request for operator approval.
|
||||||
|
# Fall back to a hard 403 when supervise isn't wired for the bottle.
|
||||||
|
if not self._supervise_available():
|
||||||
|
self._block_dlp(flow, result)
|
||||||
|
return False
|
||||||
|
approved = await self._supervise_token_block(flow, request_path, result)
|
||||||
|
if not approved:
|
||||||
|
return False # _supervise_token_block wrote the 403 response
|
||||||
|
# loop: the approved value is now in safe_tokens; re-scan.
|
||||||
|
|
||||||
|
def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
|
||||||
|
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||||
|
request surfaces (body, headers, path/query) and re-scan. Returns True
|
||||||
|
if the request is now clean; False if a block-severity match remains on
|
||||||
|
a surface redaction cannot rewrite (the hostname) so the caller fails
|
||||||
|
closed."""
|
||||||
|
body = flow.request.get_text(strict=False)
|
||||||
|
if body:
|
||||||
|
redacted_body = redact_tokens(body, env=os.environ)
|
||||||
|
if redacted_body != body:
|
||||||
|
flow.request.text = redacted_body
|
||||||
|
for name, value in list(flow.request.headers.items()):
|
||||||
|
if name.lower() == "host":
|
||||||
|
continue # routing-critical; never a legitimate token
|
||||||
|
redacted = strip_crlf(redact_tokens(value, env=os.environ))
|
||||||
|
if redacted != value:
|
||||||
|
flow.request.headers[name] = redacted
|
||||||
|
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
|
||||||
|
if redacted_path != flow.request.path:
|
||||||
|
flow.request.path = redacted_path
|
||||||
|
|
||||||
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
new_body = flow.request.get_text(strict=False) or ""
|
||||||
|
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||||
|
scan_text = build_outbound_scan_text(
|
||||||
|
flow.request.pretty_host, request_path, query, headers, new_body,
|
||||||
|
)
|
||||||
|
crlf_text = build_outbound_scan_text(
|
||||||
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
|
)
|
||||||
|
result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
|
||||||
|
return result is None or result.severity != "block"
|
||||||
|
|
||||||
|
async def _supervise_token_block(
|
||||||
|
self,
|
||||||
|
flow: http.HTTPFlow,
|
||||||
|
request_path: str,
|
||||||
|
result: ScanResult,
|
||||||
|
) -> bool:
|
||||||
|
"""Route a token DLP block to the operator's supervisor queue and wait.
|
||||||
|
|
||||||
|
Returns True if the operator approved (the matched value is added to
|
||||||
|
`self.safe_tokens` and the caller re-scans); False if the request must
|
||||||
|
be blocked (a 403 response has been written to `flow`)."""
|
||||||
|
host = flow.request.pretty_host
|
||||||
|
payload = build_token_allow_payload(
|
||||||
|
redact_tokens(host, env=os.environ),
|
||||||
|
flow.request.method,
|
||||||
|
redact_tokens(request_path, env=os.environ),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
proposal = _sv.Proposal.new(
|
||||||
|
bottle_slug=self._supervise_slug,
|
||||||
|
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
|
proposed_file=payload,
|
||||||
|
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||||
|
current_file_hash=_sv.sha256_hex(payload),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_sv.write_proposal(proposal)
|
||||||
|
except OSError as e:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"egress: could not queue token-allow proposal: {e}; "
|
||||||
|
"blocking request\n"
|
||||||
|
)
|
||||||
|
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
|
||||||
|
return False
|
||||||
|
|
||||||
|
sys.stderr.write(json.dumps({
|
||||||
|
"event": "egress_token_supervise",
|
||||||
|
"reason": f"egress DLP: {result.reason}",
|
||||||
|
"proposal": proposal.id,
|
||||||
|
**self._req_ctx(flow),
|
||||||
|
}) + "\n")
|
||||||
|
|
||||||
|
response = await self._await_token_response(proposal.id)
|
||||||
|
_sv.archive_proposal(self._supervise_slug, proposal.id)
|
||||||
|
|
||||||
|
if response is not None and response.status in (
|
||||||
|
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||||
|
):
|
||||||
|
self.safe_tokens.add(result.matched)
|
||||||
|
if self.config.log >= LOG_BLOCKS:
|
||||||
|
sys.stderr.write(json.dumps({
|
||||||
|
"event": "egress_token_allowed",
|
||||||
|
"reason": f"egress DLP: {result.reason}",
|
||||||
|
"proposal": proposal.id,
|
||||||
|
**self._req_ctx(flow),
|
||||||
|
}) + "\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
reason = (
|
||||||
|
f"egress DLP: {result.reason}; supervisor approval timed out "
|
||||||
|
f"after {self._token_allow_timeout:g}s"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = f"egress DLP: {result.reason}; supervisor rejected the request"
|
||||||
|
self._block(flow, reason, ctx=self._req_ctx(flow))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _await_token_response(
|
||||||
|
self,
|
||||||
|
proposal_id: str,
|
||||||
|
) -> "_sv.Response | None":
|
||||||
|
"""Poll the DB for the operator's response without blocking the
|
||||||
|
proxy event loop. Returns the Response, or None on timeout."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + self._token_allow_timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return _sv.read_response(self._supervise_slug, proposal_id)
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
# Not written yet, or a partial/malformed write — retry until
|
||||||
|
# the deadline, then fail closed.
|
||||||
|
pass
|
||||||
|
if loop.time() >= deadline:
|
||||||
|
return None
|
||||||
|
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
def response(self, flow: http.HTTPFlow) -> None:
|
def response(self, flow: http.HTTPFlow) -> None:
|
||||||
"""DLP inbound scan on response headers and body."""
|
"""DLP inbound scan on response headers and body."""
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||||
@@ -258,7 +505,12 @@ class EgressAddon:
|
|||||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
||||||
content = message.content.decode("utf-8", errors="replace")
|
content = message.content.decode("utf-8", errors="replace")
|
||||||
if message.from_client:
|
if message.from_client:
|
||||||
result = scan_outbound(route, content, os.environ)
|
# A WebSocket data frame is not an HTTP request line, so CRLF is
|
||||||
|
# not an injection vector here — scan only for credential leakage.
|
||||||
|
result = scan_outbound(
|
||||||
|
route, content, os.environ,
|
||||||
|
safe_tokens=self.safe_tokens, crlf_text="",
|
||||||
|
)
|
||||||
if result is not None and result.severity == "block":
|
if result is not None and result.severity == "block":
|
||||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
||||||
flow.kill() # type: ignore[union-attr]
|
flow.kill() # type: ignore[union-attr]
|
||||||
@@ -272,4 +524,23 @@ class EgressAddon:
|
|||||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _token_allow_timeout_from_env(env: "os._Environ[str]") -> float:
|
||||||
|
"""Read EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS; fall back to the default on an
|
||||||
|
unset or invalid value (a bad value should not wedge egress at boot)."""
|
||||||
|
raw = env.get("EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
value = 0.0
|
||||||
|
if value <= 0:
|
||||||
|
sys.stderr.write(
|
||||||
|
"egress: invalid EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS="
|
||||||
|
f"{raw!r}; using default {DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS:g}s\n"
|
||||||
|
)
|
||||||
|
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
addons = [EgressAddon()]
|
addons = [EgressAddon()]
|
||||||
|
|||||||
+215
-75
@@ -21,6 +21,32 @@ try:
|
|||||||
except ImportError: # pragma: no cover - host-side path
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
|
|
||||||
|
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
||||||
|
# into the sidecar — see Dockerfile.sidecars). Re-exported below so existing
|
||||||
|
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||||
|
try:
|
||||||
|
from egress_dlp_config import ( # type: ignore[import-not-found]
|
||||||
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
|
INBOUND_DETECTOR_NAMES,
|
||||||
|
ON_MATCH_BLOCK,
|
||||||
|
ON_MATCH_REDACT,
|
||||||
|
ON_MATCH_SUPERVISE,
|
||||||
|
OUTBOUND_DETECTOR_NAMES,
|
||||||
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
|
parse_dlp_block,
|
||||||
|
)
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
|
from .egress_dlp_config import (
|
||||||
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
|
INBOUND_DETECTOR_NAMES,
|
||||||
|
ON_MATCH_BLOCK,
|
||||||
|
ON_MATCH_REDACT,
|
||||||
|
ON_MATCH_SUPERVISE,
|
||||||
|
OUTBOUND_DETECTOR_NAMES,
|
||||||
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
|
parse_dlp_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Match types (Gateway API HTTPRoute vocabulary, PRD 0053)
|
# Match types (Gateway API HTTPRoute vocabulary, PRD 0053)
|
||||||
@@ -34,9 +60,6 @@ VALID_METHODS = frozenset({
|
|||||||
"CONNECT",
|
"CONNECT",
|
||||||
})
|
})
|
||||||
|
|
||||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
|
||||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PathMatch:
|
class PathMatch:
|
||||||
@@ -66,8 +89,11 @@ class Route:
|
|||||||
matches: tuple[MatchEntry, ...] = ()
|
matches: tuple[MatchEntry, ...] = ()
|
||||||
auth_scheme: str = ""
|
auth_scheme: str = ""
|
||||||
token_env: str = ""
|
token_env: str = ""
|
||||||
|
git_fetch: bool = False
|
||||||
outbound_detectors: tuple[str, ...] | None = None
|
outbound_detectors: tuple[str, ...] | None = None
|
||||||
inbound_detectors: tuple[str, ...] | None = None
|
inbound_detectors: tuple[str, ...] | None = None
|
||||||
|
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
||||||
|
outbound_on_match: str = ""
|
||||||
|
|
||||||
|
|
||||||
LOG_OFF = 0 # no logging
|
LOG_OFF = 0 # no logging
|
||||||
@@ -94,6 +120,11 @@ class ScanResult:
|
|||||||
reason: str
|
reason: str
|
||||||
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
||||||
context: str = "" # surrounding text with the match replaced by REDACT
|
context: str = "" # surrounding text with the match replaced by REDACT
|
||||||
|
# Raw substring the detector matched. Used inside the sidecar to key the
|
||||||
|
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
|
||||||
|
# to a proposal file. Empty for structural detectors (CRLF) that carry no
|
||||||
|
# safelist-able value.
|
||||||
|
matched: str = ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -213,61 +244,6 @@ def _parse_match_entry(idx: int, k: int, raw: object) -> MatchEntry:
|
|||||||
return MatchEntry(paths=paths, methods=methods, headers=headers)
|
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, ...]:
|
def parse_routes(payload: object) -> tuple[Route, ...]:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("routes payload: top-level must be an object")
|
raise ValueError("routes payload: top-level must be an object")
|
||||||
@@ -316,16 +292,35 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
f"token_env={token_env!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
|
# dlp detectors
|
||||||
outbound_detectors, inbound_detectors = _parse_detectors(
|
outbound_detectors, inbound_detectors, outbound_on_match = parse_dlp_block(
|
||||||
idx, host, raw_dict,
|
idx, host, raw_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
for k in raw_dict:
|
for k in raw_dict:
|
||||||
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp"):
|
if k not in ("host", "matches", "auth_scheme", "token_env", "dlp", "git"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
||||||
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp'"
|
f"are 'host', 'matches', 'auth_scheme', 'token_env', 'dlp', 'git'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Route(
|
return Route(
|
||||||
@@ -333,18 +328,63 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
matches=matches,
|
matches=matches,
|
||||||
auth_scheme=auth_scheme,
|
auth_scheme=auth_scheme,
|
||||||
token_env=token_env,
|
token_env=token_env,
|
||||||
|
git_fetch=git_fetch,
|
||||||
outbound_detectors=outbound_detectors,
|
outbound_detectors=outbound_detectors,
|
||||||
inbound_detectors=inbound_detectors,
|
inbound_detectors=inbound_detectors,
|
||||||
|
outbound_on_match=outbound_on_match,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_routes(text: str) -> tuple[Route, ...]:
|
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
||||||
"""Parse YAML text → routes."""
|
d: dict[str, object] = {"value": pm.value}
|
||||||
try:
|
if pm.type != "prefix":
|
||||||
payload = parse_yaml_subset(text)
|
d["type"] = pm.type
|
||||||
except YamlSubsetError as e:
|
return d
|
||||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
|
||||||
return parse_routes(payload)
|
|
||||||
|
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 r.outbound_on_match:
|
||||||
|
dlp["outbound_on_match"] = r.outbound_on_match
|
||||||
|
if dlp:
|
||||||
|
d["dlp"] = dlp
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def parse_config(payload: object) -> "Config":
|
def parse_config(payload: object) -> "Config":
|
||||||
@@ -450,6 +490,17 @@ def is_git_push_request(path: str, query: str) -> bool:
|
|||||||
return False
|
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
|
# Route lookup + decision
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -513,6 +564,24 @@ def decide(
|
|||||||
return Decision(action="forward")
|
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)
|
# DLP scan dispatch (PRD 0053)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -590,43 +659,103 @@ def scan_outbound(
|
|||||||
route: Route,
|
route: Route,
|
||||||
body: str | bytes,
|
body: str | bytes,
|
||||||
environ: typing.Mapping[str, str],
|
environ: typing.Mapping[str, str],
|
||||||
|
*,
|
||||||
|
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||||
|
crlf_text: str | None = None,
|
||||||
) -> ScanResult | None:
|
) -> ScanResult | None:
|
||||||
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
||||||
# at import time (the sidecar copies it flat alongside this file).
|
# at import time (the sidecar copies it flat alongside this file).
|
||||||
try:
|
try:
|
||||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||||
scan_crlf_injection,
|
scan_crlf_injection,
|
||||||
|
scan_entropy,
|
||||||
scan_known_secrets,
|
scan_known_secrets,
|
||||||
scan_token_patterns,
|
scan_token_patterns,
|
||||||
)
|
)
|
||||||
except ImportError: # pragma: no cover - host-side path
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from .dlp_detectors import ( # type: ignore[import-not-found]
|
from .dlp_detectors import ( # type: ignore[import-not-found]
|
||||||
scan_crlf_injection,
|
scan_crlf_injection,
|
||||||
|
scan_entropy,
|
||||||
scan_known_secrets,
|
scan_known_secrets,
|
||||||
scan_token_patterns,
|
scan_token_patterns,
|
||||||
)
|
)
|
||||||
|
|
||||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
# Binary bodies: latin-1 is a bijective byte↔codepoint mapping that
|
||||||
|
# preserves every byte value, so ASCII-range secret strings remain
|
||||||
|
# findable by str.find / regex. Prefer strict UTF-8 for valid text bodies.
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = body.decode("latin-1")
|
||||||
|
else:
|
||||||
|
text = body
|
||||||
|
|
||||||
# CRLF injection is never legitimate — runs unconditionally, not gated
|
# CRLF injection is only an attack in the request line + headers, never the
|
||||||
# by outbound_detectors config.
|
# body: an HTTP body is delimited by Content-Length, so CRLF bytes there
|
||||||
result = scan_crlf_injection(text)
|
# cannot split the request. Scanning the body produces false positives on
|
||||||
|
# legitimate form-encoded / multi-line content. Callers pass the
|
||||||
|
# body-excluded surfaces as `crlf_text`; `None` falls back to the full text
|
||||||
|
# for backward-compatible callers (host-side tests, websocket frames).
|
||||||
|
crlf_target = text if crlf_text is None else crlf_text
|
||||||
|
result = scan_crlf_injection(crlf_target)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if _detector_enabled(route.outbound_detectors, "token_patterns"):
|
if _detector_enabled(route.outbound_detectors, "token_patterns"):
|
||||||
result = scan_token_patterns(text, location="body")
|
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if _detector_enabled(route.outbound_detectors, "known_secrets"):
|
if _detector_enabled(route.outbound_detectors, "known_secrets"):
|
||||||
result = scan_known_secrets(text, location="body", env=environ)
|
# BOT_BOTTLE_SENSITIVE_PREFIXES lets operators add extra env prefixes
|
||||||
|
# beyond EGRESS_TOKEN_* without changing the manifest schema.
|
||||||
|
extra_raw = environ.get("BOT_BOTTLE_SENSITIVE_PREFIXES", "")
|
||||||
|
extra = tuple(p for p in extra_raw.split(",") if p)
|
||||||
|
sensitive_prefixes = ("EGRESS_TOKEN_",) + extra
|
||||||
|
result = scan_known_secrets(
|
||||||
|
text, location="body", env=environ,
|
||||||
|
sensitive_prefixes=sensitive_prefixes, safe_tokens=safe_tokens,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Entropy scanning requires explicit opt-in: it is NOT part of the
|
||||||
|
# default "all detectors" set because it produces false positives on
|
||||||
|
# legitimate base64 / binary payloads. Routes must list "entropy" in
|
||||||
|
# dlp.outbound_detectors to enable it.
|
||||||
|
if (
|
||||||
|
route.outbound_detectors is not None
|
||||||
|
and "entropy" in route.outbound_detectors
|
||||||
|
):
|
||||||
|
result = scan_entropy(text, location="body")
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_token_allow_payload(
|
||||||
|
host: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
result: ScanResult,
|
||||||
|
) -> str:
|
||||||
|
"""Render the human-readable supervisor proposal body for an outbound
|
||||||
|
token block (PRD 0062). Carries the host/method/path, the detector
|
||||||
|
reason, and the redacted context snippet — never the raw token value."""
|
||||||
|
lines = [
|
||||||
|
"egress blocked an outbound request carrying a detected token",
|
||||||
|
f"host: {host}",
|
||||||
|
f"method: {method}",
|
||||||
|
f"path: {path}",
|
||||||
|
f"detector: {result.reason}",
|
||||||
|
]
|
||||||
|
if result.context:
|
||||||
|
lines.append(f"context: {result.context}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def scan_inbound(
|
def scan_inbound(
|
||||||
route: Route,
|
route: Route,
|
||||||
body: str | bytes,
|
body: str | bytes,
|
||||||
@@ -648,8 +777,17 @@ def scan_inbound(
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LOG_BLOCKS",
|
"LOG_BLOCKS",
|
||||||
|
"route_to_yaml_dict",
|
||||||
"LOG_FULL",
|
"LOG_FULL",
|
||||||
"LOG_OFF",
|
"LOG_OFF",
|
||||||
|
"ON_MATCH_BLOCK",
|
||||||
|
"ON_MATCH_REDACT",
|
||||||
|
"ON_MATCH_SUPERVISE",
|
||||||
|
"OUTBOUND_ON_MATCH_VALUES",
|
||||||
|
"DEFAULT_OUTBOUND_ON_MATCH",
|
||||||
|
"OUTBOUND_DETECTOR_NAMES",
|
||||||
|
"INBOUND_DETECTOR_NAMES",
|
||||||
|
"parse_dlp_block",
|
||||||
"Config",
|
"Config",
|
||||||
"Decision",
|
"Decision",
|
||||||
"HeaderMatch",
|
"HeaderMatch",
|
||||||
@@ -659,11 +797,13 @@ __all__ = [
|
|||||||
"ScanResult",
|
"ScanResult",
|
||||||
"build_inbound_scan_text",
|
"build_inbound_scan_text",
|
||||||
"build_outbound_scan_text",
|
"build_outbound_scan_text",
|
||||||
|
"build_token_allow_payload",
|
||||||
"decide",
|
"decide",
|
||||||
|
"decide_git_fetch",
|
||||||
"evaluate_matches",
|
"evaluate_matches",
|
||||||
"is_git_push_request",
|
"is_git_push_request",
|
||||||
|
"is_git_fetch_request",
|
||||||
"load_config",
|
"load_config",
|
||||||
"load_routes",
|
|
||||||
"match_route",
|
"match_route",
|
||||||
"outbound_scan_headers",
|
"outbound_scan_headers",
|
||||||
"parse_config",
|
"parse_config",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""DLP detector-config parsing for egress routes (PRD 0053, PRD 0062).
|
||||||
|
|
||||||
|
A route's optional `dlp:` block names which outbound/inbound detectors run
|
||||||
|
and what the proxy does when an outbound detector matches a token
|
||||||
|
(`outbound_on_match`). This module owns parsing and validating that block,
|
||||||
|
kept apart from the request-time scan/decision flow in `egress_addon_core`
|
||||||
|
so each half reads top-to-bottom without scrolling past the other.
|
||||||
|
|
||||||
|
Stdlib-only; ships flat into the sidecar bundle image alongside
|
||||||
|
`egress_addon_core.py` — see `Dockerfile.sidecars`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets", "entropy"})
|
||||||
|
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
||||||
|
|
||||||
|
# Per-route policy for what the proxy does when an outbound DLP detector
|
||||||
|
# matches a token (PRD 0062).
|
||||||
|
ON_MATCH_BLOCK = "block" # hard 403, never overridable
|
||||||
|
ON_MATCH_REDACT = "redact" # scrub the matched value, forward the request
|
||||||
|
ON_MATCH_SUPERVISE = "supervise" # queue for operator approval, hold the request
|
||||||
|
OUTBOUND_ON_MATCH_VALUES = (ON_MATCH_BLOCK, ON_MATCH_REDACT, ON_MATCH_SUPERVISE)
|
||||||
|
# Unset resolves to supervise (fall back to block when supervise is not wired).
|
||||||
|
DEFAULT_OUTBOUND_ON_MATCH = ON_MATCH_SUPERVISE
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dlp_block(
|
||||||
|
idx: int,
|
||||||
|
host: str,
|
||||||
|
raw_dict: dict[str, object],
|
||||||
|
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||||
|
"""Parse the optional `dlp` block on a route, returning
|
||||||
|
(outbound_detectors, inbound_detectors, outbound_on_match)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
on_match = ""
|
||||||
|
on_match_raw = dlp.get("outbound_on_match")
|
||||||
|
if on_match_raw is not None:
|
||||||
|
if not isinstance(on_match_raw, str) or on_match_raw not in OUTBOUND_ON_MATCH_VALUES:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label}: dlp.outbound_on_match must be one of "
|
||||||
|
f"{', '.join(OUTBOUND_ON_MATCH_VALUES)} (got {on_match_raw!r})"
|
||||||
|
)
|
||||||
|
on_match = on_match_raw
|
||||||
|
|
||||||
|
for k in dlp:
|
||||||
|
if k not in ("outbound_detectors", "inbound_detectors", "outbound_on_match"):
|
||||||
|
raise ValueError(
|
||||||
|
f"{label}: dlp has unknown key {k!r}; accepted keys "
|
||||||
|
f"are 'outbound_detectors', 'inbound_detectors', "
|
||||||
|
f"'outbound_on_match'"
|
||||||
|
)
|
||||||
|
return outbound, inbound, on_match
|
||||||
@@ -37,8 +37,8 @@ if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress
|
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress
|
||||||
# directly via the docker network alias). Smolmachines backend
|
# directly via the docker network alias). A VM backend uses
|
||||||
# uses EGRESS_LISTEN_HOST when a non-default binding is needed.
|
# EGRESS_LISTEN_HOST when a non-default binding is needed.
|
||||||
LISTEN_HOST_FLAG=""
|
LISTEN_HOST_FLAG=""
|
||||||
if [ -n "$EGRESS_LISTEN_HOST" ]; then
|
if [ -n "$EGRESS_LISTEN_HOST" ]; then
|
||||||
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
|
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
|
||||||
|
|||||||
+6
-4
@@ -35,6 +35,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .errors import MissingEnvVarError
|
||||||
from .log import die
|
from .log import die
|
||||||
from .manifest import Manifest
|
from .manifest import Manifest
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
def resolve_env(manifest: Manifest) -> ResolvedEnv:
|
||||||
"""Iterate the agent's env entries:
|
"""Iterate the agent's env entries:
|
||||||
- secret: prompt at runtime; carry value in forwarded
|
- secret: prompt at runtime; carry value in forwarded
|
||||||
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
||||||
@@ -124,7 +125,7 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
|||||||
backend injects forwarded values via its launcher's env parameter."""
|
backend injects forwarded values via its launcher's env parameter."""
|
||||||
forwarded: dict[str, str] = {}
|
forwarded: dict[str, str] = {}
|
||||||
literals: dict[str, str] = {}
|
literals: dict[str, str] = {}
|
||||||
bottle = manifest.bottle_for(agent)
|
bottle = manifest.bottle
|
||||||
for name, raw in bottle.env.items():
|
for name, raw in bottle.env.items():
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
@@ -136,9 +137,10 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
|||||||
host_var = env_entry_interpolated_from(raw)
|
host_var = env_entry_interpolated_from(raw)
|
||||||
host_value = os.environ.get(host_var, "")
|
host_value = os.environ.get(host_var, "")
|
||||||
if not host_value:
|
if not host_value:
|
||||||
die(
|
raise MissingEnvVarError(
|
||||||
|
host_var,
|
||||||
f"env entry {name} is interpolated from ${host_var}, "
|
f"env entry {name} is interpolated from ${host_var}, "
|
||||||
f"but ${host_var} is unset or empty in the host environment."
|
f"but ${host_var} is unset or empty in the host environment.",
|
||||||
)
|
)
|
||||||
forwarded[name] = host_value
|
forwarded[name] = host_value
|
||||||
else: # literal
|
else: # literal
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user