dev-bubble 0.7.18__tar.gz → 0.7.20__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dev_bubble-0.7.18/dev_bubble.egg-info → dev_bubble-0.7.20}/PKG-INFO +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/SPEC.md +1 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/auth_proxy.py +149 -22
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/container_helpers.py +41 -2
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/finalization.py +4 -4
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/github_token.py +241 -119
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/graphql_validator.py +5 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/hooks/__init__.py +5 -5
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/hooks/lean.py +138 -90
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/gh.sh +34 -9
- dev_bubble-0.7.20/bubble/images/scripts/tools/pi.sh +26 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/pins.json +2 -1
- dev_bubble-0.7.20/bubble/incus_bridge.py +107 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/network.py +52 -15
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/runtime/colima.py +12 -3
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/runtime/incus.py +105 -30
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/tools.py +18 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/dev_bubble.egg-info/SOURCES.txt +4 -0
- dev_bubble-0.7.20/scratch/forkproxy-repro.sh +66 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/conftest.py +2 -0
- dev_bubble-0.7.20/tests/test_bridge_listener.py +322 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_colima.py +26 -2
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_hooks.py +118 -27
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_token_no_argv_leak.py +6 -2
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_tools.py +11 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/.gitignore +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/LICENSE +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/README.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/ai.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/automation.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/clean.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/cli.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/clone.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/internal.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/config.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/uv.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/lean.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/naming.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/notices.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/output.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/relay.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/security.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/setup.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/skill.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/conftest.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/pyproject.toml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/setup.cfg +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_auth_proxy.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_authorized_keys.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_config.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_ephemeral.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_github_security_override.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_github_token.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_graphql_validator.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_internal.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_network.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_reattach_network.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_security.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_status.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_systemd_path.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.20}/tests/test_vscode.py +0 -0
|
@@ -677,6 +677,7 @@ Each tool has:
|
|
|
677
677
|
| `elan` | 10 | `elan` | — |
|
|
678
678
|
| `claude` | 50 | `claude` | `api.anthropic.com` |
|
|
679
679
|
| `codex` | 50 | `codex` | `api.openai.com` |
|
|
680
|
+
| `pi` | 50 | `pi` | `openrouter.ai` |
|
|
680
681
|
| `gh` | 50 | `gh` | — |
|
|
681
682
|
| `vscode` | 90 | `code` | VS Code marketplace domains |
|
|
682
683
|
| `emacs` | 90 | `emacs` | — |
|
|
@@ -28,8 +28,32 @@ Security model:
|
|
|
28
28
|
- Per-container token isolation via X-Bubble-Token or Authorization header
|
|
29
29
|
- Rate limited + logged (reuses relay patterns)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
The security boundary is incus container isolation plus the secrecy of
|
|
32
|
+
the per-container token. The token is a bearer credential: it is
|
|
33
|
+
256-bit random, scoped to a single owner/repo, validated on every
|
|
34
|
+
request, and stored only in the issuing container's filesystem (mode
|
|
35
|
+
0600). The bridge listener is reachable by any container on the bridge
|
|
36
|
+
(required for git), but reachability is not access — only a valid token
|
|
37
|
+
grants the scoped GitHub operations. There is intentionally no
|
|
38
|
+
source-IP binding: it would only matter after a container-root
|
|
39
|
+
compromise had *also* stolen another bubble's token (which requires
|
|
40
|
+
breaking incus isolation), and enforcing it forced fragile,
|
|
41
|
+
platform-specific kernel/network preconditions for negligible gain.
|
|
42
|
+
|
|
43
|
+
Listeners:
|
|
44
|
+
- On Linux: TCP on the incus bridge gateway IP (typically 10.156.104.1),
|
|
45
|
+
restricted to ``incusbr0`` via ``SO_BINDTODEVICE``. Containers reach
|
|
46
|
+
the daemon directly through the bridge so no per-bubble incus proxy
|
|
47
|
+
device is needed.
|
|
48
|
+
- On macOS (Colima): TCP on the Colima bridge IP.
|
|
49
|
+
|
|
50
|
+
``gh`` reaches this same TCP listener through a tiny unix→TCP forwarder
|
|
51
|
+
that runs inside each container (see the gh tool wrapper) — gh wants a
|
|
52
|
+
Unix socket, the forwarder gives it one locally and relays to the
|
|
53
|
+
bridge. There is no host-side Unix socket and no incus ``proxy`` device.
|
|
54
|
+
|
|
55
|
+
The listener endpoint is written to ``~/.bubble/auth-proxy.endpoint``
|
|
56
|
+
(JSON). ``auth-proxy.port`` is also written for backwards compat.
|
|
33
57
|
"""
|
|
34
58
|
|
|
35
59
|
import base64
|
|
@@ -37,6 +61,7 @@ import json
|
|
|
37
61
|
import logging
|
|
38
62
|
import os
|
|
39
63
|
import re
|
|
64
|
+
import socket
|
|
40
65
|
import ssl
|
|
41
66
|
import threading
|
|
42
67
|
import time
|
|
@@ -56,6 +81,7 @@ from .token_store import RateLimiter as _RateLimiter
|
|
|
56
81
|
from .token_store import RateWindow, TokenStore, setup_file_logging
|
|
57
82
|
|
|
58
83
|
AUTH_PROXY_PORT_FILE = DATA_DIR / "auth-proxy.port"
|
|
84
|
+
AUTH_PROXY_ENDPOINT_FILE = DATA_DIR / "auth-proxy.endpoint"
|
|
59
85
|
AUTH_PROXY_LOG = DATA_DIR / "auth-proxy.log"
|
|
60
86
|
AUTH_PROXY_TOKENS = DATA_DIR / "auth-tokens.json"
|
|
61
87
|
|
|
@@ -182,6 +208,12 @@ def generate_auth_token(
|
|
|
182
208
|
The token maps to (container_name, owner, repo, rest_api, graphql_read,
|
|
183
209
|
graphql_write) — the proxy uses this to validate requests and enforce
|
|
184
210
|
the access policy.
|
|
211
|
+
|
|
212
|
+
The token is a 256-bit bearer credential: possession grants the
|
|
213
|
+
scoped access. It lives only in the issuing container's filesystem
|
|
214
|
+
(mode 0600); cross-container isolation is incus's job. There is no
|
|
215
|
+
source-IP binding — see the security notes in the module docstring.
|
|
216
|
+
|
|
185
217
|
Uses file locking to prevent read-modify-write races.
|
|
186
218
|
"""
|
|
187
219
|
return TokenStore(AUTH_PROXY_TOKENS).generate(
|
|
@@ -690,9 +722,10 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
690
722
|
self.send_header(header, value)
|
|
691
723
|
|
|
692
724
|
def _authenticate(self) -> dict | None:
|
|
693
|
-
"""Authenticate the request via
|
|
725
|
+
"""Authenticate the request via its bearer token.
|
|
694
726
|
|
|
695
|
-
Returns the token info dict or None (sends error response).
|
|
727
|
+
Returns the token info dict or None (sends error response). The
|
|
728
|
+
token is the capability; possession grants the scoped access.
|
|
696
729
|
"""
|
|
697
730
|
token = self._get_container_token()
|
|
698
731
|
if not token:
|
|
@@ -1379,6 +1412,40 @@ class ThreadedHTTPServer(HTTPServer):
|
|
|
1379
1412
|
self._handler_semaphore.release()
|
|
1380
1413
|
|
|
1381
1414
|
|
|
1415
|
+
class BridgeBoundHTTPServer(ThreadedHTTPServer):
|
|
1416
|
+
"""ThreadedHTTPServer that restricts the listening socket to one
|
|
1417
|
+
network interface via ``SO_BINDTODEVICE``.
|
|
1418
|
+
|
|
1419
|
+
The kernel rejects packets arriving on any other interface, even if
|
|
1420
|
+
they're addressed to the bind IP — so a misrouted packet from the
|
|
1421
|
+
LAN side, docker0, etc. cannot reach this listener. We also verify
|
|
1422
|
+
the option round-trips via ``getsockopt`` and fail closed if it
|
|
1423
|
+
didn't take effect (older kernels, namespaces, etc.).
|
|
1424
|
+
"""
|
|
1425
|
+
|
|
1426
|
+
def __init__(self, server_address, RequestHandlerClass, *, bind_device: str):
|
|
1427
|
+
self._bind_device = bind_device
|
|
1428
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
1429
|
+
|
|
1430
|
+
def server_bind(self):
|
|
1431
|
+
if not hasattr(socket, "SO_BINDTODEVICE"):
|
|
1432
|
+
raise RuntimeError(
|
|
1433
|
+
"SO_BINDTODEVICE is unavailable on this platform; refusing to "
|
|
1434
|
+
"bind a bridge-restricted listener."
|
|
1435
|
+
)
|
|
1436
|
+
device_bytes = self._bind_device.encode("ascii") + b"\x00"
|
|
1437
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device_bytes)
|
|
1438
|
+
# Verify the option actually took effect — fail closed if not.
|
|
1439
|
+
bound = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, 16)
|
|
1440
|
+
if not bound.startswith(self._bind_device.encode("ascii")):
|
|
1441
|
+
raise RuntimeError(
|
|
1442
|
+
f"SO_BINDTODEVICE did not bind to {self._bind_device!r}; "
|
|
1443
|
+
f"getsockopt returned {bound!r}. Refusing to start."
|
|
1444
|
+
)
|
|
1445
|
+
# Now do the actual bind.
|
|
1446
|
+
super().server_bind()
|
|
1447
|
+
|
|
1448
|
+
|
|
1382
1449
|
# ---------------------------------------------------------------------------
|
|
1383
1450
|
# Daemon
|
|
1384
1451
|
# ---------------------------------------------------------------------------
|
|
@@ -1438,10 +1505,70 @@ def _get_github_token() -> str:
|
|
|
1438
1505
|
return token
|
|
1439
1506
|
|
|
1440
1507
|
|
|
1508
|
+
def _resolve_tcp_bind() -> tuple[str, str | None]:
|
|
1509
|
+
"""Pick the TCP bind address for this platform.
|
|
1510
|
+
|
|
1511
|
+
Returns ``(bind_addr, bind_device)``. ``bind_device`` is the network
|
|
1512
|
+
interface name to restrict via SO_BINDTODEVICE, or None if no
|
|
1513
|
+
restriction applies (macOS).
|
|
1514
|
+
|
|
1515
|
+
Raises if no usable bridge address can be found. We deliberately do
|
|
1516
|
+
NOT fall back to ``127.0.0.1``: containers can't reach the host's
|
|
1517
|
+
loopback, so a loopback bind would produce a listener that bubbles
|
|
1518
|
+
configure against but can never connect to. Failing here means the
|
|
1519
|
+
daemon doesn't start, no endpoint file is written, and local auth
|
|
1520
|
+
setup fails closed.
|
|
1521
|
+
"""
|
|
1522
|
+
import platform
|
|
1523
|
+
|
|
1524
|
+
if platform.system() == "Darwin":
|
|
1525
|
+
# On macOS, incus runs inside a Colima VM; bind to the VMNet
|
|
1526
|
+
# bridge IP so the VM can reach us. No SO_BINDTODEVICE — macOS
|
|
1527
|
+
# doesn't support it, and the VMNet IP is already only on the
|
|
1528
|
+
# bridge interface.
|
|
1529
|
+
from .runtime.colima import colima_bind_ip
|
|
1530
|
+
|
|
1531
|
+
addr = colima_bind_ip()
|
|
1532
|
+
if addr.startswith("127."):
|
|
1533
|
+
raise RuntimeError(
|
|
1534
|
+
"Could not determine the Colima bridge IP (got loopback). "
|
|
1535
|
+
"Is the bubble-colima VM running? Refusing to bind a "
|
|
1536
|
+
"container-unreachable loopback address."
|
|
1537
|
+
)
|
|
1538
|
+
return addr, None
|
|
1539
|
+
|
|
1540
|
+
# Linux: bind to the incus bridge gateway IP and restrict the
|
|
1541
|
+
# listener to incusbr0 so it's unreachable from any other interface.
|
|
1542
|
+
from .incus_bridge import BRIDGE_INTERFACE, bridge_gateway_ipv4
|
|
1543
|
+
|
|
1544
|
+
return bridge_gateway_ipv4(), BRIDGE_INTERFACE
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _write_endpoint_file(tcp_host: str, tcp_port: int):
|
|
1548
|
+
"""Persist the TCP listener endpoint for bubble's auth-setup code."""
|
|
1549
|
+
payload = {
|
|
1550
|
+
"tcp": {"host": tcp_host, "port": tcp_port},
|
|
1551
|
+
"version": 3,
|
|
1552
|
+
}
|
|
1553
|
+
AUTH_PROXY_ENDPOINT_FILE.write_text(json.dumps(payload))
|
|
1554
|
+
os.chmod(str(AUTH_PROXY_ENDPOINT_FILE), 0o600)
|
|
1555
|
+
# Backwards-compat: keep auth-proxy.port readable as an int.
|
|
1556
|
+
AUTH_PROXY_PORT_FILE.write_text(str(tcp_port))
|
|
1557
|
+
os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
|
|
1558
|
+
|
|
1559
|
+
|
|
1441
1560
|
def run_daemon(port: int = 0):
|
|
1442
|
-
"""Run the auth proxy daemon.
|
|
1561
|
+
"""Run the auth proxy daemon: a single TCP listener.
|
|
1443
1562
|
|
|
1444
|
-
|
|
1563
|
+
Containers reach it directly over the incus bridge. git talks to it
|
|
1564
|
+
via ``url.insteadOf``; gh talks to it via a small unix→TCP forwarder
|
|
1565
|
+
that runs inside each container (see the gh tool wrapper) — so there
|
|
1566
|
+
is no host-side Unix socket and no incus ``proxy`` device anywhere.
|
|
1567
|
+
|
|
1568
|
+
On Linux the listener is bound to the incus bridge gateway IP and
|
|
1569
|
+
restricted to ``incusbr0`` via ``SO_BINDTODEVICE`` (verified
|
|
1570
|
+
failure-closed via a ``getsockopt`` round-trip). On macOS it binds
|
|
1571
|
+
the Colima bridge IP.
|
|
1445
1572
|
|
|
1446
1573
|
Args:
|
|
1447
1574
|
port: Port to listen on. 0 means use config or default.
|
|
@@ -1467,29 +1594,29 @@ def run_daemon(port: int = 0):
|
|
|
1467
1594
|
AuthProxyHandler.rate_limiter = rate_limiter
|
|
1468
1595
|
AuthProxyHandler.token_refresher = token_refresher
|
|
1469
1596
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if platform.system() == "Darwin":
|
|
1476
|
-
from .runtime.colima import colima_bind_ip
|
|
1477
|
-
|
|
1478
|
-
bind_addr = colima_bind_ip()
|
|
1597
|
+
bind_addr, bind_device = _resolve_tcp_bind()
|
|
1598
|
+
if bind_device:
|
|
1599
|
+
tcp_server = BridgeBoundHTTPServer(
|
|
1600
|
+
(bind_addr, port), AuthProxyHandler, bind_device=bind_device
|
|
1601
|
+
)
|
|
1479
1602
|
else:
|
|
1480
|
-
|
|
1481
|
-
server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
|
|
1603
|
+
tcp_server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
|
|
1482
1604
|
|
|
1483
|
-
|
|
1484
|
-
os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
|
|
1605
|
+
_write_endpoint_file(bind_addr, port)
|
|
1485
1606
|
|
|
1486
|
-
logger.info(
|
|
1607
|
+
logger.info(
|
|
1608
|
+
"Auth proxy daemon started: tcp=%s:%d (device=%s)",
|
|
1609
|
+
bind_addr,
|
|
1610
|
+
port,
|
|
1611
|
+
bind_device or "(unrestricted)",
|
|
1612
|
+
)
|
|
1487
1613
|
print(f"Auth proxy listening on {bind_addr}:{port}")
|
|
1488
1614
|
|
|
1489
1615
|
try:
|
|
1490
|
-
|
|
1616
|
+
tcp_server.serve_forever()
|
|
1491
1617
|
except KeyboardInterrupt:
|
|
1492
1618
|
logger.info("Auth proxy daemon stopped")
|
|
1493
1619
|
finally:
|
|
1494
|
-
|
|
1620
|
+
tcp_server.shutdown()
|
|
1495
1621
|
AUTH_PROXY_PORT_FILE.unlink(missing_ok=True)
|
|
1622
|
+
AUTH_PROXY_ENDPOINT_FILE.unlink(missing_ok=True)
|
|
@@ -273,16 +273,55 @@ def apply_network(
|
|
|
273
273
|
gh_level = get_github_level(config)
|
|
274
274
|
if gh_level != "direct" and not (keep_github_domains and gh_level != "off"):
|
|
275
275
|
domains = filter_github_domains(domains)
|
|
276
|
-
|
|
276
|
+
# If the bridge-listener auth proxy is running, punch a hole for the
|
|
277
|
+
# bridge IP:port so the container can reach it directly. Bubbles
|
|
278
|
+
# using the legacy proxy-device flow don't need this (the proxy
|
|
279
|
+
# device delivers traffic on the container's own loopback).
|
|
280
|
+
auth_endpoint = _resolve_auth_proxy_endpoint_for_allowlist(gh_level)
|
|
281
|
+
if domains or auth_endpoint:
|
|
277
282
|
try:
|
|
278
283
|
from .network import apply_allowlist
|
|
279
284
|
|
|
280
|
-
apply_allowlist(runtime, name, domains)
|
|
285
|
+
apply_allowlist(runtime, name, domains, auth_proxy_endpoint=auth_endpoint)
|
|
281
286
|
detail("Network allowlist applied.")
|
|
282
287
|
except (RuntimeError, OSError, ValueError) as e:
|
|
283
288
|
raise click.ClickException(f"Failed to apply network allowlist: {e}")
|
|
284
289
|
|
|
285
290
|
|
|
291
|
+
def _resolve_auth_proxy_endpoint_for_allowlist(gh_level: str) -> tuple[str, int] | None:
|
|
292
|
+
"""Return the bridge auth-proxy endpoint if the bridge flow is active.
|
|
293
|
+
|
|
294
|
+
Returns None when github auth is disabled, when injecting the raw
|
|
295
|
+
token (no proxy needed), or when the daemon hasn't written the v2
|
|
296
|
+
endpoint file (legacy flow). When set, the container needs an
|
|
297
|
+
explicit iptables ACCEPT for the endpoint so it can reach the
|
|
298
|
+
proxy directly (bridge IP on Linux; the Colima bridge IP on macOS,
|
|
299
|
+
reached via the VM's NAT).
|
|
300
|
+
"""
|
|
301
|
+
import json
|
|
302
|
+
|
|
303
|
+
if gh_level in ("off", "direct"):
|
|
304
|
+
return None
|
|
305
|
+
from .auth_proxy import AUTH_PROXY_ENDPOINT_FILE
|
|
306
|
+
|
|
307
|
+
if not AUTH_PROXY_ENDPOINT_FILE.exists():
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
data = json.loads(AUTH_PROXY_ENDPOINT_FILE.read_text())
|
|
311
|
+
except (json.JSONDecodeError, OSError):
|
|
312
|
+
return None
|
|
313
|
+
tcp = data.get("tcp") or {}
|
|
314
|
+
host = tcp.get("host")
|
|
315
|
+
port = tcp.get("port")
|
|
316
|
+
if not host or not isinstance(port, int):
|
|
317
|
+
return None
|
|
318
|
+
# Don't punch a hole for loopback — that's the legacy bind fallback
|
|
319
|
+
# and the proxy device handles delivery internally.
|
|
320
|
+
if host == "127.0.0.1":
|
|
321
|
+
return None
|
|
322
|
+
return (host, port)
|
|
323
|
+
|
|
324
|
+
|
|
286
325
|
def detect_project_dir(runtime: ContainerRuntime, name: str) -> str:
|
|
287
326
|
"""Detect the project directory inside a container.
|
|
288
327
|
|
|
@@ -38,11 +38,11 @@ def finalize_bubble(
|
|
|
38
38
|
before any clone/fetch operations.
|
|
39
39
|
"""
|
|
40
40
|
q_short = shlex.quote(short)
|
|
41
|
-
|
|
42
|
-
subdir = hook.project_subdir() if hook else ""
|
|
43
|
-
project_dir = f"{repo_dir}/{subdir}" if subdir else repo_dir
|
|
41
|
+
project_dir = f"/home/user/{short}"
|
|
44
42
|
if hook:
|
|
45
43
|
hook.post_clone(runtime, name, project_dir)
|
|
44
|
+
for note in hook.notices():
|
|
45
|
+
click.echo(note, err=True)
|
|
46
46
|
|
|
47
47
|
# Add a "github" remote with SSH-format URL for gh CLI host discovery.
|
|
48
48
|
# The global url.insteadOf rewrites HTTPS github.com URLs to the proxy,
|
|
@@ -52,7 +52,7 @@ def finalize_bubble(
|
|
|
52
52
|
# letting gh discover the host without needing to actually use the remote.
|
|
53
53
|
if t.owner and t.repo:
|
|
54
54
|
q_repo = shlex.quote(f"git@github.com:{t.owner}/{t.repo}.git")
|
|
55
|
-
q_dir = shlex.quote(
|
|
55
|
+
q_dir = shlex.quote(project_dir)
|
|
56
56
|
add_cmd = f"cd {q_dir} && git remote add github {q_repo} 2>/dev/null || true"
|
|
57
57
|
try:
|
|
58
58
|
runtime.exec(name, ["su", "-", "user", "-c", add_cmd])
|