dev-bubble 0.7.19__tar.gz → 0.7.21__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 (144) hide show
  1. {dev_bubble-0.7.19/dev_bubble.egg-info → dev_bubble-0.7.21}/PKG-INFO +1 -1
  2. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/SPEC.md +1 -0
  3. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/auth_proxy.py +158 -27
  5. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/config.py +9 -0
  6. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/container_helpers.py +41 -2
  7. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/github_token.py +241 -119
  8. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/graphql_validator.py +5 -0
  9. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/gh.sh +34 -9
  10. dev_bubble-0.7.21/bubble/images/scripts/tools/pi.sh +26 -0
  11. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/pins.json +2 -1
  12. dev_bubble-0.7.21/bubble/incus_bridge.py +107 -0
  13. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/network.py +52 -15
  14. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/colima.py +12 -3
  15. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/incus.py +113 -30
  16. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/tools.py +18 -0
  17. {dev_bubble-0.7.19 → dev_bubble-0.7.21/dev_bubble.egg-info}/PKG-INFO +1 -1
  18. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/SOURCES.txt +5 -0
  19. dev_bubble-0.7.21/scratch/forkproxy-repro.sh +66 -0
  20. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/conftest.py +10 -0
  21. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_auth_proxy.py +56 -1
  22. dev_bubble-0.7.21/tests/test_bridge_listener.py +322 -0
  23. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_colima.py +26 -2
  24. dev_bubble-0.7.21/tests/test_get_info_remote.py +138 -0
  25. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_token_no_argv_leak.py +6 -2
  26. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_tools.py +11 -1
  27. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.claude/CLAUDE.md +0 -0
  28. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.github/workflows/ci.yml +0 -0
  29. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.github/workflows/publish.yml +0 -0
  30. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.gitignore +0 -0
  31. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/CHANGELOG.md +0 -0
  32. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/LICENSE +0 -0
  33. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/README.md +0 -0
  34. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/__main__.py +0 -0
  35. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/ai.py +0 -0
  36. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/automation.py +0 -0
  37. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/clean.py +0 -0
  38. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cli.py +0 -0
  39. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/clone.py +0 -0
  40. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cloud.py +0 -0
  41. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cloud_types.py +0 -0
  42. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/__init__.py +0 -0
  43. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/cloud_cmd.py +0 -0
  44. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/completion.py +0 -0
  45. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/doctor.py +0 -0
  46. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/images.py +0 -0
  47. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/infrastructure.py +0 -0
  48. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/internal.py +0 -0
  49. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/lifecycle.py +0 -0
  50. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/list_cmd.py +0 -0
  51. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/relay_cmd.py +0 -0
  52. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/remote_cmd.py +0 -0
  53. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/security_cmd.py +0 -0
  54. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/settings.py +0 -0
  55. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/status_cmd.py +0 -0
  56. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/data/skill.md +0 -0
  57. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/default_repos.json +0 -0
  58. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/finalization.py +0 -0
  59. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/git_store.py +0 -0
  60. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/__init__.py +0 -0
  61. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/lean.py +0 -0
  62. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/python.py +0 -0
  63. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/image_management.py +0 -0
  64. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/__init__.py +0 -0
  65. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/builder.py +0 -0
  66. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/base.sh +0 -0
  67. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/cloud-init.sh +0 -0
  68. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/lean-toolchain.sh +0 -0
  69. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/lean.sh +0 -0
  70. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/python.sh +0 -0
  71. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/claude.sh +0 -0
  72. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/codex.sh +0 -0
  73. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/elan.sh +0 -0
  74. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/emacs.sh +0 -0
  75. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/neovim.sh +0 -0
  76. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/uv.sh +0 -0
  77. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/vscode.sh +0 -0
  78. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/lean.py +0 -0
  79. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/lifecycle.py +0 -0
  80. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/naming.py +0 -0
  81. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/notices.py +0 -0
  82. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/output.py +0 -0
  83. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/provisioning.py +0 -0
  84. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/relay.py +0 -0
  85. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/remote.py +0 -0
  86. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/repo_registry.py +0 -0
  87. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/__init__.py +0 -0
  88. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/base.py +0 -0
  89. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/security.py +0 -0
  90. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/setup.py +0 -0
  91. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/skill.py +0 -0
  92. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/spinner.py +0 -0
  93. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/target.py +0 -0
  94. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/token_store.py +0 -0
  95. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/tunnel.py +0 -0
  96. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/vscode.py +0 -0
  97. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.git-update.plist +0 -0
  98. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.image-refresh.plist +0 -0
  99. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.relay-daemon.plist +0 -0
  100. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/conftest.py +0 -0
  101. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/dependency_links.txt +0 -0
  102. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/entry_points.txt +0 -0
  103. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/requires.txt +0 -0
  104. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/top_level.txt +0 -0
  105. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/pyproject.toml +0 -0
  106. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/setup.cfg +0 -0
  107. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_ai.py +0 -0
  108. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_authorized_keys.py +0 -0
  109. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_branch_no_target.py +0 -0
  110. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_build_lock.py +0 -0
  111. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_claude_projects_symlink.py +0 -0
  112. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_cloud.py +0 -0
  113. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_completion.py +0 -0
  114. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_config.py +0 -0
  115. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_customize.py +0 -0
  116. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_editor.py +0 -0
  117. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_ephemeral.py +0 -0
  118. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_git_store.py +0 -0
  119. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_github_security_override.py +0 -0
  120. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_github_token.py +0 -0
  121. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_graphql_validator.py +0 -0
  122. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_hooks.py +0 -0
  123. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_integration.py +0 -0
  124. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_internal.py +0 -0
  125. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_lifecycle.py +0 -0
  126. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_list_columns.py +0 -0
  127. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_list_remote.py +0 -0
  128. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_mounts.py +0 -0
  129. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_multi_target.py +0 -0
  130. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_naming.py +0 -0
  131. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_network.py +0 -0
  132. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_notices.py +0 -0
  133. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_reattach_network.py +0 -0
  134. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_relay.py +0 -0
  135. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_remote.py +0 -0
  136. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_repo_registry.py +0 -0
  137. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_security.py +0 -0
  138. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_skill.py +0 -0
  139. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_spinner.py +0 -0
  140. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_status.py +0 -0
  141. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_systemd_path.py +0 -0
  142. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_target.py +0 -0
  143. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_tunnel.py +0 -0
  144. {dev_bubble-0.7.19 → dev_bubble-0.7.21}/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.21
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.21"
@@ -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
@@ -51,13 +76,18 @@ from urllib.request import (
51
76
  build_opener,
52
77
  )
53
78
 
54
- from .config import DATA_DIR
79
+ from .config import AUTH_PROXY_DIR
55
80
  from .token_store import RateLimiter as _RateLimiter
56
81
  from .token_store import RateWindow, TokenStore, setup_file_logging
57
82
 
58
- AUTH_PROXY_PORT_FILE = DATA_DIR / "auth-proxy.port"
59
- AUTH_PROXY_LOG = DATA_DIR / "auth-proxy.log"
60
- AUTH_PROXY_TOKENS = DATA_DIR / "auth-tokens.json"
83
+ # The daemon is a host singleton that always uses ~/.bubble (see
84
+ # AUTH_PROXY_DIR in config.py). Both the daemon and callers running under a
85
+ # custom BUBBLE_HOME must resolve these files against that fixed location so
86
+ # they agree on where the endpoint and per-container tokens live.
87
+ AUTH_PROXY_PORT_FILE = AUTH_PROXY_DIR / "auth-proxy.port"
88
+ AUTH_PROXY_ENDPOINT_FILE = AUTH_PROXY_DIR / "auth-proxy.endpoint"
89
+ AUTH_PROXY_LOG = AUTH_PROXY_DIR / "auth-proxy.log"
90
+ AUTH_PROXY_TOKENS = AUTH_PROXY_DIR / "auth-tokens.json"
61
91
 
62
92
  # Default port (configurable via config.toml)
63
93
  DEFAULT_PORT = 7654
@@ -182,6 +212,12 @@ def generate_auth_token(
182
212
  The token maps to (container_name, owner, repo, rest_api, graphql_read,
183
213
  graphql_write) — the proxy uses this to validate requests and enforce
184
214
  the access policy.
215
+
216
+ The token is a 256-bit bearer credential: possession grants the
217
+ scoped access. It lives only in the issuing container's filesystem
218
+ (mode 0600); cross-container isolation is incus's job. There is no
219
+ source-IP binding — see the security notes in the module docstring.
220
+
185
221
  Uses file locking to prevent read-modify-write races.
186
222
  """
187
223
  return TokenStore(AUTH_PROXY_TOKENS).generate(
@@ -690,9 +726,10 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
690
726
  self.send_header(header, value)
691
727
 
692
728
  def _authenticate(self) -> dict | None:
693
- """Authenticate the request via X-Bubble-Token.
729
+ """Authenticate the request via its bearer token.
694
730
 
695
- Returns the token info dict or None (sends error response).
731
+ Returns the token info dict or None (sends error response). The
732
+ token is the capability; possession grants the scoped access.
696
733
  """
697
734
  token = self._get_container_token()
698
735
  if not token:
@@ -1379,6 +1416,40 @@ class ThreadedHTTPServer(HTTPServer):
1379
1416
  self._handler_semaphore.release()
1380
1417
 
1381
1418
 
1419
+ class BridgeBoundHTTPServer(ThreadedHTTPServer):
1420
+ """ThreadedHTTPServer that restricts the listening socket to one
1421
+ network interface via ``SO_BINDTODEVICE``.
1422
+
1423
+ The kernel rejects packets arriving on any other interface, even if
1424
+ they're addressed to the bind IP — so a misrouted packet from the
1425
+ LAN side, docker0, etc. cannot reach this listener. We also verify
1426
+ the option round-trips via ``getsockopt`` and fail closed if it
1427
+ didn't take effect (older kernels, namespaces, etc.).
1428
+ """
1429
+
1430
+ def __init__(self, server_address, RequestHandlerClass, *, bind_device: str):
1431
+ self._bind_device = bind_device
1432
+ super().__init__(server_address, RequestHandlerClass)
1433
+
1434
+ def server_bind(self):
1435
+ if not hasattr(socket, "SO_BINDTODEVICE"):
1436
+ raise RuntimeError(
1437
+ "SO_BINDTODEVICE is unavailable on this platform; refusing to "
1438
+ "bind a bridge-restricted listener."
1439
+ )
1440
+ device_bytes = self._bind_device.encode("ascii") + b"\x00"
1441
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device_bytes)
1442
+ # Verify the option actually took effect — fail closed if not.
1443
+ bound = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, 16)
1444
+ if not bound.startswith(self._bind_device.encode("ascii")):
1445
+ raise RuntimeError(
1446
+ f"SO_BINDTODEVICE did not bind to {self._bind_device!r}; "
1447
+ f"getsockopt returned {bound!r}. Refusing to start."
1448
+ )
1449
+ # Now do the actual bind.
1450
+ super().server_bind()
1451
+
1452
+
1382
1453
  # ---------------------------------------------------------------------------
1383
1454
  # Daemon
1384
1455
  # ---------------------------------------------------------------------------
@@ -1438,16 +1509,76 @@ def _get_github_token() -> str:
1438
1509
  return token
1439
1510
 
1440
1511
 
1512
+ def _resolve_tcp_bind() -> tuple[str, str | None]:
1513
+ """Pick the TCP bind address for this platform.
1514
+
1515
+ Returns ``(bind_addr, bind_device)``. ``bind_device`` is the network
1516
+ interface name to restrict via SO_BINDTODEVICE, or None if no
1517
+ restriction applies (macOS).
1518
+
1519
+ Raises if no usable bridge address can be found. We deliberately do
1520
+ NOT fall back to ``127.0.0.1``: containers can't reach the host's
1521
+ loopback, so a loopback bind would produce a listener that bubbles
1522
+ configure against but can never connect to. Failing here means the
1523
+ daemon doesn't start, no endpoint file is written, and local auth
1524
+ setup fails closed.
1525
+ """
1526
+ import platform
1527
+
1528
+ if platform.system() == "Darwin":
1529
+ # On macOS, incus runs inside a Colima VM; bind to the VMNet
1530
+ # bridge IP so the VM can reach us. No SO_BINDTODEVICE — macOS
1531
+ # doesn't support it, and the VMNet IP is already only on the
1532
+ # bridge interface.
1533
+ from .runtime.colima import colima_bind_ip
1534
+
1535
+ addr = colima_bind_ip()
1536
+ if addr.startswith("127."):
1537
+ raise RuntimeError(
1538
+ "Could not determine the Colima bridge IP (got loopback). "
1539
+ "Is the bubble-colima VM running? Refusing to bind a "
1540
+ "container-unreachable loopback address."
1541
+ )
1542
+ return addr, None
1543
+
1544
+ # Linux: bind to the incus bridge gateway IP and restrict the
1545
+ # listener to incusbr0 so it's unreachable from any other interface.
1546
+ from .incus_bridge import BRIDGE_INTERFACE, bridge_gateway_ipv4
1547
+
1548
+ return bridge_gateway_ipv4(), BRIDGE_INTERFACE
1549
+
1550
+
1551
+ def _write_endpoint_file(tcp_host: str, tcp_port: int):
1552
+ """Persist the TCP listener endpoint for bubble's auth-setup code."""
1553
+ payload = {
1554
+ "tcp": {"host": tcp_host, "port": tcp_port},
1555
+ "version": 3,
1556
+ }
1557
+ AUTH_PROXY_ENDPOINT_FILE.write_text(json.dumps(payload))
1558
+ os.chmod(str(AUTH_PROXY_ENDPOINT_FILE), 0o600)
1559
+ # Backwards-compat: keep auth-proxy.port readable as an int.
1560
+ AUTH_PROXY_PORT_FILE.write_text(str(tcp_port))
1561
+ os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
1562
+
1563
+
1441
1564
  def run_daemon(port: int = 0):
1442
- """Run the auth proxy daemon.
1565
+ """Run the auth proxy daemon: a single TCP listener.
1443
1566
 
1444
- Listens on TCP (both macOS and Linux HTTP needs TCP).
1567
+ Containers reach it directly over the incus bridge. git talks to it
1568
+ via ``url.insteadOf``; gh talks to it via a small unix→TCP forwarder
1569
+ that runs inside each container (see the gh tool wrapper) — so there
1570
+ is no host-side Unix socket and no incus ``proxy`` device anywhere.
1571
+
1572
+ On Linux the listener is bound to the incus bridge gateway IP and
1573
+ restricted to ``incusbr0`` via ``SO_BINDTODEVICE`` (verified
1574
+ failure-closed via a ``getsockopt`` round-trip). On macOS it binds
1575
+ the Colima bridge IP.
1445
1576
 
1446
1577
  Args:
1447
1578
  port: Port to listen on. 0 means use config or default.
1448
1579
  """
1449
1580
  _setup_logging()
1450
- DATA_DIR.mkdir(parents=True, exist_ok=True)
1581
+ AUTH_PROXY_DIR.mkdir(parents=True, exist_ok=True)
1451
1582
 
1452
1583
  if not port:
1453
1584
  from .config import load_config
@@ -1467,29 +1598,29 @@ def run_daemon(port: int = 0):
1467
1598
  AuthProxyHandler.rate_limiter = rate_limiter
1468
1599
  AuthProxyHandler.token_refresher = token_refresher
1469
1600
 
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()
1601
+ bind_addr, bind_device = _resolve_tcp_bind()
1602
+ if bind_device:
1603
+ tcp_server = BridgeBoundHTTPServer(
1604
+ (bind_addr, port), AuthProxyHandler, bind_device=bind_device
1605
+ )
1479
1606
  else:
1480
- bind_addr = "127.0.0.1"
1481
- server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
1607
+ tcp_server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
1482
1608
 
1483
- AUTH_PROXY_PORT_FILE.write_text(str(port))
1484
- os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
1609
+ _write_endpoint_file(bind_addr, port)
1485
1610
 
1486
- logger.info("Auth proxy daemon started on %s:%d", bind_addr, port)
1611
+ logger.info(
1612
+ "Auth proxy daemon started: tcp=%s:%d (device=%s)",
1613
+ bind_addr,
1614
+ port,
1615
+ bind_device or "(unrestricted)",
1616
+ )
1487
1617
  print(f"Auth proxy listening on {bind_addr}:{port}")
1488
1618
 
1489
1619
  try:
1490
- server.serve_forever()
1620
+ tcp_server.serve_forever()
1491
1621
  except KeyboardInterrupt:
1492
1622
  logger.info("Auth proxy daemon stopped")
1493
1623
  finally:
1494
- server.shutdown()
1624
+ tcp_server.shutdown()
1495
1625
  AUTH_PROXY_PORT_FILE.unlink(missing_ok=True)
1626
+ AUTH_PROXY_ENDPOINT_FILE.unlink(missing_ok=True)
@@ -20,6 +20,15 @@ import tomli_w
20
20
  # Override with BUBBLE_HOME environment variable
21
21
  DATA_DIR = Path(os.environ.get("BUBBLE_HOME", Path.home() / ".bubble"))
22
22
  CONFIG_FILE = DATA_DIR / "config.toml"
23
+
24
+ # The auth-proxy daemon is a host singleton, installed via launchd/systemd
25
+ # with no BUBBLE_HOME in its environment, so it always runs against the
26
+ # default ~/.bubble and writes its endpoint/port/token/log files there.
27
+ # Callers (e.g. `bubble open`) may run under a custom BUBBLE_HOME, but must
28
+ # resolve those daemon files against the daemon's fixed location rather than
29
+ # their own DATA_DIR — otherwise they look for an endpoint the daemon never
30
+ # wrote and write tokens the daemon never reads. See issue #304.
31
+ AUTH_PROXY_DIR = Path.home() / ".bubble"
23
32
  REGISTRY_FILE = DATA_DIR / "registry.json"
24
33
  GIT_DIR = DATA_DIR / "git"
25
34
  REPOS_FILE = DATA_DIR / "repos.json"
@@ -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