dev-bubble 0.7.19__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.
Files changed (143) hide show
  1. {dev_bubble-0.7.19/dev_bubble.egg-info → dev_bubble-0.7.20}/PKG-INFO +1 -1
  2. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/SPEC.md +1 -0
  3. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/auth_proxy.py +149 -22
  5. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/container_helpers.py +41 -2
  6. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/github_token.py +241 -119
  7. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/graphql_validator.py +5 -0
  8. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/gh.sh +34 -9
  9. dev_bubble-0.7.20/bubble/images/scripts/tools/pi.sh +26 -0
  10. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/pins.json +2 -1
  11. dev_bubble-0.7.20/bubble/incus_bridge.py +107 -0
  12. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/network.py +52 -15
  13. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/runtime/colima.py +12 -3
  14. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/runtime/incus.py +105 -30
  15. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/tools.py +18 -0
  16. {dev_bubble-0.7.19 → dev_bubble-0.7.20/dev_bubble.egg-info}/PKG-INFO +1 -1
  17. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/dev_bubble.egg-info/SOURCES.txt +4 -0
  18. dev_bubble-0.7.20/scratch/forkproxy-repro.sh +66 -0
  19. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/conftest.py +2 -0
  20. dev_bubble-0.7.20/tests/test_bridge_listener.py +322 -0
  21. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_colima.py +26 -2
  22. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_token_no_argv_leak.py +6 -2
  23. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_tools.py +11 -1
  24. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/.claude/CLAUDE.md +0 -0
  25. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/.github/workflows/ci.yml +0 -0
  26. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/.github/workflows/publish.yml +0 -0
  27. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/.gitignore +0 -0
  28. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/CHANGELOG.md +0 -0
  29. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/LICENSE +0 -0
  30. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/README.md +0 -0
  31. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/__main__.py +0 -0
  32. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/ai.py +0 -0
  33. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/automation.py +0 -0
  34. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/clean.py +0 -0
  35. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/cli.py +0 -0
  36. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/clone.py +0 -0
  37. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/cloud.py +0 -0
  38. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/cloud_types.py +0 -0
  39. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/__init__.py +0 -0
  40. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/cloud_cmd.py +0 -0
  41. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/completion.py +0 -0
  42. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/doctor.py +0 -0
  43. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/images.py +0 -0
  44. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/infrastructure.py +0 -0
  45. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/internal.py +0 -0
  46. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/lifecycle.py +0 -0
  47. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/list_cmd.py +0 -0
  48. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/relay_cmd.py +0 -0
  49. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/remote_cmd.py +0 -0
  50. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/security_cmd.py +0 -0
  51. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/settings.py +0 -0
  52. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/commands/status_cmd.py +0 -0
  53. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/config.py +0 -0
  54. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/data/skill.md +0 -0
  55. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/default_repos.json +0 -0
  56. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/finalization.py +0 -0
  57. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/git_store.py +0 -0
  58. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/hooks/__init__.py +0 -0
  59. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/hooks/lean.py +0 -0
  60. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/hooks/python.py +0 -0
  61. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/image_management.py +0 -0
  62. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/__init__.py +0 -0
  63. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/builder.py +0 -0
  64. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/base.sh +0 -0
  65. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/cloud-init.sh +0 -0
  66. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/lean-toolchain.sh +0 -0
  67. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/lean.sh +0 -0
  68. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/python.sh +0 -0
  69. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/claude.sh +0 -0
  70. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/codex.sh +0 -0
  71. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/elan.sh +0 -0
  72. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/emacs.sh +0 -0
  73. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/neovim.sh +0 -0
  74. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/uv.sh +0 -0
  75. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/images/scripts/tools/vscode.sh +0 -0
  76. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/lean.py +0 -0
  77. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/lifecycle.py +0 -0
  78. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/naming.py +0 -0
  79. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/notices.py +0 -0
  80. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/output.py +0 -0
  81. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/provisioning.py +0 -0
  82. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/relay.py +0 -0
  83. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/remote.py +0 -0
  84. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/repo_registry.py +0 -0
  85. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/runtime/__init__.py +0 -0
  86. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/runtime/base.py +0 -0
  87. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/security.py +0 -0
  88. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/setup.py +0 -0
  89. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/skill.py +0 -0
  90. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/spinner.py +0 -0
  91. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/target.py +0 -0
  92. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/token_store.py +0 -0
  93. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/tunnel.py +0 -0
  94. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/bubble/vscode.py +0 -0
  95. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/config/com.bubble.git-update.plist +0 -0
  96. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/config/com.bubble.image-refresh.plist +0 -0
  97. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/config/com.bubble.relay-daemon.plist +0 -0
  98. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/conftest.py +0 -0
  99. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/dev_bubble.egg-info/dependency_links.txt +0 -0
  100. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/dev_bubble.egg-info/entry_points.txt +0 -0
  101. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/dev_bubble.egg-info/requires.txt +0 -0
  102. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/dev_bubble.egg-info/top_level.txt +0 -0
  103. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/pyproject.toml +0 -0
  104. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/setup.cfg +0 -0
  105. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_ai.py +0 -0
  106. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_auth_proxy.py +0 -0
  107. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_authorized_keys.py +0 -0
  108. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_branch_no_target.py +0 -0
  109. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_build_lock.py +0 -0
  110. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_claude_projects_symlink.py +0 -0
  111. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_cloud.py +0 -0
  112. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_completion.py +0 -0
  113. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_config.py +0 -0
  114. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_customize.py +0 -0
  115. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_editor.py +0 -0
  116. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_ephemeral.py +0 -0
  117. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_git_store.py +0 -0
  118. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_github_security_override.py +0 -0
  119. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_github_token.py +0 -0
  120. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_graphql_validator.py +0 -0
  121. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_hooks.py +0 -0
  122. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_integration.py +0 -0
  123. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_internal.py +0 -0
  124. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_lifecycle.py +0 -0
  125. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_list_columns.py +0 -0
  126. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_list_remote.py +0 -0
  127. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_mounts.py +0 -0
  128. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_multi_target.py +0 -0
  129. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_naming.py +0 -0
  130. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_network.py +0 -0
  131. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_notices.py +0 -0
  132. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_reattach_network.py +0 -0
  133. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_relay.py +0 -0
  134. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_remote.py +0 -0
  135. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_repo_registry.py +0 -0
  136. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_security.py +0 -0
  137. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_skill.py +0 -0
  138. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_spinner.py +0 -0
  139. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_status.py +0 -0
  140. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_systemd_path.py +0 -0
  141. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_target.py +0 -0
  142. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_tunnel.py +0 -0
  143. {dev_bubble-0.7.19 → dev_bubble-0.7.20}/tests/test_vscode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.19
3
+ Version: 0.7.20
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.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` | — |
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.19"
3
+ __version__ = "0.7.20"
@@ -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
- On macOS (Colima): TCP listener, port saved to ~/.bubble/auth-proxy.port.
32
- On Linux: TCP listener on 127.0.0.1 (Incus proxy needs TCP for HTTP).
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 X-Bubble-Token.
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
- Listens on TCP (both macOS and Linux HTTP needs TCP).
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
- # On macOS, Incus runs inside a Colima VM. Bind to the VMNet bridge
1471
- # IP (e.g. 192.168.64.1) so the VM can reach us without exposing the
1472
- # service to the wider LAN. Falls back to 0.0.0.0 if no bridge found.
1473
- import platform
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
- bind_addr = "127.0.0.1"
1481
- server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
1603
+ tcp_server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
1482
1604
 
1483
- AUTH_PROXY_PORT_FILE.write_text(str(port))
1484
- os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
1605
+ _write_endpoint_file(bind_addr, port)
1485
1606
 
1486
- logger.info("Auth proxy daemon started on %s:%d", bind_addr, port)
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
- server.serve_forever()
1616
+ tcp_server.serve_forever()
1491
1617
  except KeyboardInterrupt:
1492
1618
  logger.info("Auth proxy daemon stopped")
1493
1619
  finally:
1494
- server.shutdown()
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
- if domains:
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