dev-bubble 0.7.11__tar.gz → 0.7.17__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.11 → dev_bubble-0.7.17}/.claude/CLAUDE.md +3 -1
  2. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/CHANGELOG.md +15 -0
  3. {dev_bubble-0.7.11/dev_bubble.egg-info → dev_bubble-0.7.17}/PKG-INFO +2 -2
  4. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/README.md +1 -1
  5. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/SPEC.md +14 -28
  6. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/__init__.py +1 -1
  7. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/ai.py +22 -12
  8. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/auth_proxy.py +227 -43
  9. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/automation.py +20 -0
  10. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/clean.py +0 -133
  11. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cli.py +203 -86
  12. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/doctor.py +5 -19
  13. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/infrastructure.py +12 -3
  14. dev_bubble-0.7.17/bubble/commands/internal.py +106 -0
  15. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/lifecycle.py +107 -93
  16. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/list_cmd.py +1 -32
  17. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/settings.py +3 -2
  18. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/status_cmd.py +2 -6
  19. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/config.py +72 -15
  20. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/container_helpers.py +154 -12
  21. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/finalization.py +61 -19
  22. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/github_token.py +153 -75
  23. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/graphql_validator.py +13 -12
  24. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/builder.py +9 -17
  25. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/base.sh +6 -1
  26. dev_bubble-0.7.17/bubble/images/scripts/python.sh +22 -0
  27. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/gh.sh +21 -1
  28. dev_bubble-0.7.17/bubble/images/scripts/tools/uv.sh +20 -0
  29. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/lifecycle.py +37 -13
  30. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/provisioning.py +2 -1
  31. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/remote.py +23 -2
  32. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/base.py +15 -1
  33. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/colima.py +106 -50
  34. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/incus.py +76 -20
  35. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/security.py +9 -0
  36. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/setup.py +7 -0
  37. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/tools.py +11 -2
  38. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/vscode.py +107 -59
  39. {dev_bubble-0.7.11 → dev_bubble-0.7.17/dev_bubble.egg-info}/PKG-INFO +2 -2
  40. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/SOURCES.txt +10 -2
  41. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/conftest.py +1 -3
  42. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_auth_proxy.py +436 -0
  43. dev_bubble-0.7.17/tests/test_authorized_keys.py +129 -0
  44. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_build_lock.py +20 -5
  45. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_claude_projects_symlink.py +49 -0
  46. dev_bubble-0.7.17/tests/test_colima.py +187 -0
  47. dev_bubble-0.7.17/tests/test_editor.py +318 -0
  48. dev_bubble-0.7.17/tests/test_ephemeral.py +158 -0
  49. dev_bubble-0.7.17/tests/test_github_security_override.py +314 -0
  50. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_github_token.py +12 -2
  51. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_graphql_validator.py +11 -8
  52. dev_bubble-0.7.17/tests/test_internal.py +208 -0
  53. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_lifecycle.py +66 -6
  54. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_mounts.py +95 -0
  55. dev_bubble-0.7.17/tests/test_multi_target.py +55 -0
  56. dev_bubble-0.7.17/tests/test_reattach_network.py +191 -0
  57. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_remote.py +32 -0
  58. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_status.py +2 -18
  59. dev_bubble-0.7.17/tests/test_systemd_path.py +26 -0
  60. dev_bubble-0.7.17/tests/test_token_no_argv_leak.py +161 -0
  61. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_vscode.py +75 -0
  62. dev_bubble-0.7.11/bubble/images/scripts/python.sh +0 -18
  63. dev_bubble-0.7.11/bubble/native.py +0 -300
  64. dev_bubble-0.7.11/tests/test_editor.py +0 -158
  65. dev_bubble-0.7.11/tests/test_multi_target.py +0 -29
  66. dev_bubble-0.7.11/tests/test_native.py +0 -198
  67. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.github/workflows/ci.yml +0 -0
  68. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.github/workflows/publish.yml +0 -0
  69. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.gitignore +0 -0
  70. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/LICENSE +0 -0
  71. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/__main__.py +0 -0
  72. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/clone.py +0 -0
  73. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cloud.py +0 -0
  74. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cloud_types.py +0 -0
  75. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/__init__.py +0 -0
  76. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/cloud_cmd.py +0 -0
  77. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/completion.py +0 -0
  78. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/images.py +0 -0
  79. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/relay_cmd.py +0 -0
  80. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/remote_cmd.py +0 -0
  81. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/security_cmd.py +0 -0
  82. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/data/skill.md +0 -0
  83. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/default_repos.json +0 -0
  84. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/git_store.py +0 -0
  85. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/__init__.py +0 -0
  86. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/lean.py +0 -0
  87. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/python.py +0 -0
  88. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/image_management.py +0 -0
  89. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/__init__.py +0 -0
  90. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/cloud-init.sh +0 -0
  91. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/lean-toolchain.sh +0 -0
  92. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/lean.sh +0 -0
  93. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/claude.sh +0 -0
  94. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/codex.sh +0 -0
  95. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/elan.sh +0 -0
  96. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/emacs.sh +0 -0
  97. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/neovim.sh +0 -0
  98. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/pins.json +0 -0
  99. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/vscode.sh +0 -0
  100. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/lean.py +0 -0
  101. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/naming.py +0 -0
  102. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/network.py +0 -0
  103. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/notices.py +0 -0
  104. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/output.py +0 -0
  105. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/relay.py +0 -0
  106. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/repo_registry.py +0 -0
  107. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/__init__.py +0 -0
  108. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/skill.py +0 -0
  109. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/spinner.py +0 -0
  110. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/target.py +0 -0
  111. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/token_store.py +0 -0
  112. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/tunnel.py +0 -0
  113. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.git-update.plist +0 -0
  114. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.image-refresh.plist +0 -0
  115. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.relay-daemon.plist +0 -0
  116. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/conftest.py +0 -0
  117. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/dependency_links.txt +0 -0
  118. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/entry_points.txt +0 -0
  119. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/requires.txt +0 -0
  120. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/top_level.txt +0 -0
  121. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/pyproject.toml +0 -0
  122. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/setup.cfg +0 -0
  123. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_ai.py +0 -0
  124. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_branch_no_target.py +0 -0
  125. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_cloud.py +0 -0
  126. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_completion.py +0 -0
  127. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_config.py +0 -0
  128. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_customize.py +0 -0
  129. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_git_store.py +0 -0
  130. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_hooks.py +0 -0
  131. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_integration.py +0 -0
  132. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_list_columns.py +0 -0
  133. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_list_remote.py +0 -0
  134. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_naming.py +0 -0
  135. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_network.py +0 -0
  136. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_notices.py +0 -0
  137. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_relay.py +0 -0
  138. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_repo_registry.py +0 -0
  139. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_security.py +0 -0
  140. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_skill.py +0 -0
  141. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_spinner.py +0 -0
  142. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_target.py +0 -0
  143. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_tools.py +0 -0
  144. {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_tunnel.py +0 -0
@@ -96,7 +96,7 @@ created → running ⇄ paused → destroyed
96
96
  ```
97
97
 
98
98
  ### Network Allowlisting
99
- Uses iptables rules inside containers (not Incus ACLs) for portability across Colima/native setups. IPv6 is blocked entirely. DNS restricted to container resolver only. No outbound SSH. Base allowlist comes from config.toml; hooks contribute additional domains (e.g., Lean adds `releases.lean-lang.org`); enabled tools contribute runtime domains (e.g., vscode adds marketplace.visualstudio.com).
99
+ Uses iptables rules inside containers (not Incus ACLs) for portability across runtimes (e.g., Colima on macOS, native Incus on Linux). IPv6 is blocked entirely. DNS restricted to container resolver only. No outbound SSH. Base allowlist comes from config.toml; hooks contribute additional domains (e.g., Lean adds `releases.lean-lang.org`); enabled tools contribute runtime domains (e.g., vscode adds marketplace.visualstudio.com).
100
100
 
101
101
  ### Editor Selection
102
102
  The default editor is VSCode via Remote SSH. Use `--shell` for a plain SSH session, `--emacs` or `--neovim` for those editors. The editor is installed as a tool in the base image (so it's pre-baked, not installed per-container). The `open_editor()` function in `vscode.py` dispatches to the appropriate launcher.
@@ -139,6 +139,8 @@ The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication with
139
139
 
140
140
  **gh CLI flow:** `gh` configured with `http_unix_socket: /bubble/gh-proxy.sock` (via `GH_CONFIG_DIR=/etc/bubble/gh`) → sends requests through Unix socket → proxy validates token from `Authorization` header → enforces access level (REST repo-scoping, GraphQL mutation filtering) → adds real token → forwards to `https://api.github.com`.
141
141
 
142
+ **gh host discovery:** `git remote -v` applies `insteadOf` to displayed URLs, so `gh` sees only proxy URLs for HTTPS remotes and can't match them to `github.com`. Two mechanisms ensure `gh` works reliably: (1) `GH_REPO=owner/repo` bypasses remote URL parsing entirely — set via `/etc/profile.d/bubble-gh.sh` for login shells and via a wrapper at `/usr/local/bin/gh` that reads `/etc/bubble/gh/repo` for non-login shells; (2) a `github` remote with SSH-format URL (`git@github.com:owner/repo.git`) is added after clone as a fallback for commands that don't respect `GH_REPO`. SSH URLs bypass the HTTPS `insteadOf` rule. The `github` remote is not used for git operations — all pushes/pulls go through `origin` (which routes through the proxy).
143
+
142
144
  **GraphQL validation** (`graphql_validator.py`): GraphQL access is controlled by two independent axes — `graphql_read` and `graphql_write` — each supporting `whitelisted`, `unrestricted`, or `none` modes. The default is `whitelisted` for both. In whitelisted mode, a lightweight tokenizer/parser validates structure (single operation, single top-level field, no aliases/directives, no fragments in mutations) and semantics. Read validation repo-scopes `repository` queries via variables, verifies `node` queries via pre-flight ownership checks, and checks second-level fields against an allowlist. Write validation checks mutations against an allowlist (createPullRequest, addComment, mergePullRequest, etc.) with repo-scoping via repositoryId comparison or pre-flight node ownership verification.
143
145
 
144
146
  **REST security:** REST paths validated against `/repos/{owner}/{repo}/...` (repo-scoped). All HTTP methods are allowed when REST access is enabled, since path validation already constrains access to the scoped repo. API redirects (e.g. CI log downloads) followed with hardened rules: GET/HEAD only, HTTPS only, allowlisted hosts, max 2 hops, auth headers stripped. GitHub 4xx errors are passed through to clients (not collapsed to 502).
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+ - Remove `--native` mode entirely
5
+ - `bubble --native <target>` is no longer accepted (use `bubble open` for containerized bubbles)
6
+ - `bubble.native` module deleted; `check_native_clean`, `open_editor_native`, and `NATIVE_DIR` removed
7
+ - `register_bubble()` no longer accepts `native` / `native_path`
8
+ - Legacy `native: true` registry entries are silently dropped on next registry load
9
+
10
+ ## 0.7.16 — 2026-05-07
11
+ - Granular Claude/Codex config mounts (#264)
12
+ - `--claude-config/--no-claude-config` and `--codex-config/--no-codex-config` flags on `bubble open`
13
+ - `[claude] config` / `[codex] config` keys in `~/.bubble/config.toml`
14
+ - Allows mounting credentials without exposing personal `~/.claude` config items (CLAUDE.md, skills, commands, settings.json, keybindings.json) — useful for autonomous agents that need to authenticate but should not be biased by the host user's preferences
15
+ - `SafeConfigDir.config_mounts` gains `include_config_items` parameter (default `True`, preserves existing behavior)
16
+ - Flags forwarded to remote/cloud bubbles
17
+
3
18
  ## 0.7.1 — 2026-03-12
4
19
  - Deprecate `config security`, `config lockdown`, `config accept-risks` in favor of `security` subcommands (#150)
5
20
  - Add deprecation warnings (to stderr) when deprecated commands are used
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.11
3
+ Version: 0.7.17
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
@@ -182,7 +182,7 @@ Each "bubble" is a lightweight Linux container (via Incus) with:
182
182
  - **IPv6 blocked**: All IPv6 traffic is dropped
183
183
  - **DNS restricted**: DNS queries only go to the container's configured resolver
184
184
  - **No outbound SSH**: Containers cannot SSH out (VSCode uses `incus exec` ProxyCommand)
185
- - **SSH key-only auth**: Password authentication is disabled
185
+ - **SSH key-only auth**: Password authentication is disabled. By default only `~/.ssh/id_ed25519.pub` is injected as `authorized_keys` (with `id_rsa.pub`/`id_ecdsa.pub` as fallbacks if no ed25519 key exists). Override with `[ssh] authorized_keys = "~/.ssh/yubikey.pub"` (or a list) in `~/.bubble/config.toml`, or with the `BUBBLE_AUTHORIZED_KEYS` env var (colon-separated paths).
186
186
  - **Shell injection hardening**: All user-supplied values are quoted with `shlex.quote()`
187
187
  - **Per-repo git mount**: Each container only sees its own bare repo, not the entire git store
188
188
  - **Bubble-in-bubble relay**: Containers can open new bubbles on the host, but only for repos already cloned in `~/.bubble/git/`. Disable with `bubble security set relay off`
@@ -148,7 +148,7 @@ Each "bubble" is a lightweight Linux container (via Incus) with:
148
148
  - **IPv6 blocked**: All IPv6 traffic is dropped
149
149
  - **DNS restricted**: DNS queries only go to the container's configured resolver
150
150
  - **No outbound SSH**: Containers cannot SSH out (VSCode uses `incus exec` ProxyCommand)
151
- - **SSH key-only auth**: Password authentication is disabled
151
+ - **SSH key-only auth**: Password authentication is disabled. By default only `~/.ssh/id_ed25519.pub` is injected as `authorized_keys` (with `id_rsa.pub`/`id_ecdsa.pub` as fallbacks if no ed25519 key exists). Override with `[ssh] authorized_keys = "~/.ssh/yubikey.pub"` (or a list) in `~/.bubble/config.toml`, or with the `BUBBLE_AUTHORIZED_KEYS` env var (colon-separated paths).
152
152
  - **Shell injection hardening**: All user-supplied values are quoted with `shlex.quote()`
153
153
  - **Per-repo git mount**: Each container only sees its own bare repo, not the entire git store
154
154
  - **Bubble-in-bubble relay**: Containers can open new bubbles on the host, but only for repos already cloned in `~/.bubble/git/`. Disable with `bubble security set relay off`
@@ -51,7 +51,6 @@ equivalent to `bubble open <url>`.
51
51
  | `--network/--no-network` | flag | `--network` | Apply network allowlist |
52
52
  | `--name` | string | | Custom container name |
53
53
  | `--command` | string | | Run command via SSH (implies shell) |
54
- | `--native` | flag | | Non-containerized workspace |
55
54
  | `--path` | flag | | Force interpretation as local path |
56
55
  | `-b`, `--new-branch` | string | | Create a new branch |
57
56
  | `--base` | string | | Base branch for `-b` |
@@ -59,6 +58,8 @@ equivalent to `bubble open <url>`.
59
58
  | `--ai-config/--no-ai-config` | flag | enabled | Mount AI provider configs read-only |
60
59
  | `--claude-credentials/--no-claude-credentials` | flag | enabled | Mount Claude credentials |
61
60
  | `--codex-credentials/--no-codex-credentials` | flag | enabled | Mount Codex credentials |
61
+ | `--claude-config/--no-claude-config` | flag | enabled | Mount Claude config items (CLAUDE.md, skills, commands, ...) |
62
+ | `--codex-config/--no-codex-config` | flag | enabled | Mount Codex config items |
62
63
  | `--ssh HOST` | string | | Run on remote host |
63
64
  | `--cloud` | flag | | Run on Hetzner Cloud server |
64
65
  | `--local` | flag | | Force local execution |
@@ -251,7 +252,10 @@ forwarding doesn't work reliably through Colima on macOS.
251
252
  | `neovim` | `ssh bubble-<name> -t "cd /home/user/<repo> && nvim ."` |
252
253
  | `shell` | `ssh bubble-<name>` |
253
254
 
254
- For `--command CMD`: `ssh bubble-<name> <cmd args...>`
255
+ For `--command CMD`: `ssh bubble-<name> <CMD>` — the `CMD` string is passed
256
+ to ssh as a single argument, so it is parsed by the remote shell exactly
257
+ as if typed after `ssh bubble-<name>` interactively. Quoting is preserved
258
+ verbatim; no host-side `shlex.split` is performed.
255
259
 
256
260
  ### 1.10 Reattachment
257
261
 
@@ -508,9 +512,9 @@ directory's git remote.
508
512
 
509
513
  **`bubble list [--json] [-v|--verbose] [-c|--clean]`**
510
514
 
511
- List active bubbles. Shows local containers, native workspaces, and remote
512
- bubbles. Verbose mode includes IP and disk usage. Clean mode checks each
513
- container's cleanness status.
515
+ List active bubbles. Shows local containers and remote bubbles. Verbose mode
516
+ includes IP and disk usage. Clean mode checks each container's cleanness
517
+ status.
514
518
 
515
519
  JSON output format:
516
520
  ```json
@@ -547,9 +551,6 @@ Destroy a bubble permanently:
547
551
  6. Remove relay tokens
548
552
  7. Unregister from registry
549
553
 
550
- **For native workspaces:** Delete the directory under `~/.bubble/native/`.
551
- Safety check: refuse to delete paths not under `~/.bubble/native/`.
552
-
553
554
  ### 4.2 Cleanness checking
554
555
 
555
556
  A container is "clean" (safe to discard) when ALL of:
@@ -579,9 +580,7 @@ A container is "clean" (safe to discard) when ALL of:
579
580
  "pr": 12345,
580
581
  "created_at": "2026-03-12T10:30:00+00:00",
581
582
  "base_image": "lean-v4.16.0",
582
- "remote_host": "user@example.com",
583
- "native": true,
584
- "native_path": "/home/user/.bubble/native/project"
583
+ "remote_host": "user@example.com"
585
584
  }
586
585
  }
587
586
  }
@@ -589,7 +588,7 @@ A container is "clean" (safe to discard) when ALL of:
589
588
 
590
589
  **Always present:** `org_repo`, `branch`, `commit`, `pr`, `created_at`.
591
590
  **Conditionally present** (only written when truthy): `base_image`,
592
- `remote_host`, `native`, `native_path`.
591
+ `remote_host`.
593
592
 
594
593
  Registry modifications MUST use file locking to prevent concurrent corruption.
595
594
  Writes MUST be atomic (write to temp file, rename).
@@ -617,8 +616,9 @@ on next use.
617
616
  ### 5.1 Mechanism
618
617
 
619
618
  Network allowlisting uses iptables rules **inside the container** (not Incus
620
- ACLs), for portability across Colima/native setups. Rules are applied by
621
- `incus exec` as root — the `user` account has no sudo and cannot modify them.
619
+ ACLs), for portability across runtimes (e.g., Colima on macOS, native Incus on
620
+ Linux). Rules are applied by `incus exec` as root — the `user` account has no
621
+ sudo and cannot modify them.
622
622
 
623
623
  ### 5.2 Rules
624
624
 
@@ -1180,19 +1180,6 @@ enforces rate limits and (for levels below `write-graphql`) repo-scoping.
1180
1180
 
1181
1181
  ---
1182
1182
 
1183
- ## Native workspaces
1184
-
1185
- `bubble open --native <target>` creates a non-containerized workspace:
1186
- - Clones to `~/.bubble/native/<name>/`
1187
- - Tracked in registry with `native: true`
1188
- - No network isolation, no container
1189
- - `pop` deletes the directory (safety check: only under `~/.bubble/native/`)
1190
- - `pause` is not supported
1191
-
1192
- **Incompatible with:** `--ssh`, `--cloud`, `--no-network`, `--machine-readable`
1193
-
1194
- ---
1195
-
1196
1183
  ## Additional commands
1197
1184
 
1198
1185
  These commands support infrastructure management and are not core to the
@@ -1224,7 +1211,6 @@ container lifecycle, but a complete implementation should include them:
1224
1211
  | `~/.bubble/git/<repo>.git.lock` | Per-repo file locks |
1225
1212
  | `~/.bubble/repos.json` | Learned repo short name mappings |
1226
1213
  | `~/.bubble/registry.json` | Bubble state tracking |
1227
- | `~/.bubble/native/` | Native workspace clones |
1228
1214
  | `~/.bubble/relay.sock` | Relay daemon Unix socket (Linux) |
1229
1215
  | `~/.bubble/relay.port` | Relay daemon TCP port (macOS) |
1230
1216
  | `~/.bubble/relay-tokens.json` | Relay auth tokens (mode 0600) |
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.11"
3
+ __version__ = "0.7.17"
@@ -10,6 +10,7 @@ import json
10
10
  import re
11
11
  import shlex
12
12
  import subprocess
13
+ import textwrap
13
14
  from pathlib import Path
14
15
 
15
16
  from .config import DATA_DIR
@@ -422,18 +423,27 @@ def inject_ai_task(
422
423
  )
423
424
  runtime.exec(container, ["su", "-", "user", "-c", script])
424
425
 
425
- # Configure settings.json for automatic tasks
426
- settings_script = (
427
- f"cd {q_dir} && "
428
- f'python3 -c "'
429
- f"import json,os; "
430
- f"p='.vscode/settings.json'; "
431
- f"s=json.load(open(p)) if os.path.exists(p) else {{}}; "
432
- f"s['terminal.integrated.defaultLocation']='editor'; "
433
- f"s['task.allowAutomaticTasks']='on'; "
434
- f"json.dump(s,open(p,'w'),indent=2)"
435
- f'"'
436
- )
426
+ # Configure settings.json for automatic tasks.
427
+ # The existing file may be JSONC (comments, trailing commas), so we
428
+ # strip those before parsing and fall back to an empty dict on failure.
429
+ settings_py = textwrap.dedent("""\
430
+ import json, re, os
431
+ p = '.vscode/settings.json'
432
+ s = {}
433
+ if os.path.exists(p):
434
+ t = open(p).read()
435
+ t = re.sub(r'//[^\\n]*', '', t)
436
+ t = re.sub(r'/\\*.*?\\*/', '', t, flags=re.DOTALL)
437
+ t = re.sub(r',\\s*([}\\]])', r'\\1', t)
438
+ try:
439
+ s = json.loads(t)
440
+ except Exception:
441
+ pass
442
+ s['terminal.integrated.defaultLocation'] = 'editor'
443
+ s['task.allowAutomaticTasks'] = 'on'
444
+ json.dump(s, open(p, 'w'), indent=2)
445
+ """)
446
+ settings_script = f"cd {q_dir} && python3 -c {shlex.quote(settings_py)}"
437
447
  runtime.exec(container, ["su", "-", "user", "-c", settings_script])
438
448
 
439
449
  # Add generated files to git exclude
@@ -39,6 +39,7 @@ import os
39
39
  import re
40
40
  import ssl
41
41
  import threading
42
+ import time
42
43
  from http.server import BaseHTTPRequestHandler, HTTPServer
43
44
  from urllib.error import HTTPError
44
45
  from urllib.parse import urlparse
@@ -74,6 +75,14 @@ RATE_LIMIT_PER_HOUR = 600
74
75
  # Maximum tracked containers
75
76
  MAX_TRACKED_CONTAINERS = 100
76
77
 
78
+ # Pre-flight result cache TTLs (seconds). Positive results are cached longer
79
+ # to avoid re-querying for legitimate repeated mutations on the same node.
80
+ # Negative results are cached briefly to blunt loops on bad/never-resolving IDs
81
+ # without keeping stale "missing" answers around.
82
+ PREFLIGHT_POSITIVE_CACHE_TTL = 300.0
83
+ PREFLIGHT_NEGATIVE_CACHE_TTL = 60.0
84
+ MAX_PREFLIGHT_CACHE_ENTRIES = 1024
85
+
77
86
  # ---------------------------------------------------------------------------
78
87
  # Path patterns
79
88
  # ---------------------------------------------------------------------------
@@ -118,6 +127,40 @@ _REDIRECT_ALLOWED_HOSTS = [
118
127
  "*.githubusercontent.com",
119
128
  ]
120
129
 
130
+ # Response headers stripped before forwarding to the container. The container
131
+ # authenticates to the proxy with its bubble token only — no other state should
132
+ # bridge container and GitHub. The hop-by-hop entries are stripped because the
133
+ # proxy terminates the upstream connection (RFC 7230 §6.1). The remaining
134
+ # entries close auxiliary channels: Authorization / Proxy-Authorization could
135
+ # echo upstream credentials; Set-Cookie would let GitHub (or an attacker
136
+ # substituting an upstream response) plant client-side state the container
137
+ # could replay against an exfil target; WWW-Authenticate / Proxy-Authenticate
138
+ # prompt the client into a separate auth scheme outside the bubble token model.
139
+ _STRIPPED_RESPONSE_HEADERS = frozenset(
140
+ {
141
+ # Hop-by-hop (RFC 7230 §6.1)
142
+ "transfer-encoding",
143
+ "connection",
144
+ "keep-alive",
145
+ "te",
146
+ "trailer",
147
+ "upgrade",
148
+ "proxy-authenticate",
149
+ "proxy-authorization",
150
+ # Auxiliary auth/state channels
151
+ "authorization",
152
+ "set-cookie",
153
+ "www-authenticate",
154
+ }
155
+ )
156
+
157
+ # On 4xx responses the Location header is purely informational (no redirect
158
+ # follow happens) and would disclose upstream redirect targets — including
159
+ # blob-storage URLs that already passed our redirect allowlist. Strip it on
160
+ # error pass-through but keep it on 3xx so the container can follow redirects
161
+ # the proxy explicitly chose not to handle.
162
+ _STRIPPED_4XX_RESPONSE_HEADERS = _STRIPPED_RESPONSE_HEADERS | {"location"}
163
+
121
164
  logger = logging.getLogger("bubble.auth_proxy")
122
165
 
123
166
 
@@ -467,10 +510,13 @@ def _is_redirect_host_allowed(host: str) -> bool:
467
510
 
468
511
  def _follow_redirect(
469
512
  location: str, hops_remaining: int, method: str = "GET"
470
- ) -> tuple[int, dict, bytes]:
513
+ ) -> tuple[int, list[tuple[str, str]], bytes]:
471
514
  """Follow a redirect URL with hardened rules.
472
515
 
473
- Returns (status_code, headers_dict, body).
516
+ Returns (status_code, headers_list, body). Headers are returned as a
517
+ list of (name, value) tuples to preserve repeated headers (e.g. Link
518
+ pagination headers).
519
+
474
520
  Raises ValueError on policy violations.
475
521
  """
476
522
  parsed = urlparse(location)
@@ -513,8 +559,7 @@ def _follow_redirect(
513
559
  raise ValueError("Redirect response too large")
514
560
  body_parts.append(chunk)
515
561
 
516
- headers = {k: v for k, v in resp.getheaders()}
517
- return resp.status, headers, b"".join(body_parts)
562
+ return resp.status, resp.getheaders(), b"".join(body_parts)
518
563
 
519
564
 
520
565
  # ---------------------------------------------------------------------------
@@ -604,6 +649,18 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
604
649
  _repo_node_id_cache: dict[tuple[str, str], str] = {}
605
650
  _repo_node_id_lock = threading.Lock()
606
651
 
652
+ # Pre-flight node-id resolution cache. Stores both positive and negative
653
+ # results with TTLs to avoid re-querying GitHub for repeated probes.
654
+ # Maps node_id -> (resolved_repo_or_None, expiry_unix_ts).
655
+ _preflight_cache: dict[str, tuple[str | None, float]] = {}
656
+ _preflight_cache_lock = threading.Lock()
657
+
658
+ # In-flight singleflight tracking. Prevents the threaded HTTP server from
659
+ # firing N concurrent host-side preflights for the same node id when N
660
+ # handlers all miss the cache simultaneously.
661
+ _preflight_inflight: dict[str, threading.Event] = {}
662
+ _preflight_inflight_lock = threading.Lock()
663
+
607
664
  def log_message(self, format, *args):
608
665
  """Route HTTP server logs to our logger."""
609
666
  logger.info(format, *args)
@@ -625,6 +682,13 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
625
682
  return auth[7:].strip()
626
683
  return None
627
684
 
685
+ def _copy_response_headers(self, headers_iter, stripped=_STRIPPED_RESPONSE_HEADERS):
686
+ """Forward upstream response headers, dropping anything in `stripped`."""
687
+ for header, value in headers_iter:
688
+ if header.lower() in stripped:
689
+ continue
690
+ self.send_header(header, value)
691
+
628
692
  def _authenticate(self) -> dict | None:
629
693
  """Authenticate the request via X-Bubble-Token.
630
694
 
@@ -842,8 +906,8 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
842
906
  parsed,
843
907
  owner,
844
908
  repo,
845
- preflight_fn=self._preflight_check,
846
- repo_node_id_fn=self._get_repo_node_id,
909
+ preflight_fn=lambda nid: self._preflight_check(nid, container),
910
+ repo_node_id_fn=lambda o, r: self._get_repo_node_id(o, r, container),
847
911
  )
848
912
  if error:
849
913
  self._send_error(403, f"GraphQL mutation validation failed: {error}")
@@ -867,7 +931,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
867
931
  parsed,
868
932
  owner,
869
933
  repo,
870
- preflight_fn=self._preflight_check,
934
+ preflight_fn=lambda nid: self._preflight_check(nid, container),
871
935
  )
872
936
  if error:
873
937
  self._send_error(403, f"GraphQL read validation failed: {error}")
@@ -898,14 +962,28 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
898
962
  resp = opener.open(req, timeout=30)
899
963
  return json.loads(resp.read())
900
964
 
901
- def _get_repo_node_id(self, owner: str, repo: str) -> str | None:
902
- """Get the GitHub node ID for a repository. Cached."""
965
+ def _get_repo_node_id(self, owner: str, repo: str, container: str) -> str | None:
966
+ """Get the GitHub node ID for a repository. Cached.
967
+
968
+ Counts uncached upstream queries against the originating container's
969
+ rate window so misbehaving containers can't burn the host's GitHub
970
+ quota through preflight traffic.
971
+ """
903
972
  key = (owner.lower(), repo.lower())
904
973
  with self._repo_node_id_lock:
905
974
  cached = self._repo_node_id_cache.get(key)
906
975
  if cached is not None:
907
976
  return cached
908
977
 
978
+ if not self.rate_limiter.check(container):
979
+ logger.info(
980
+ "PREFLIGHT rate-limited container=%s repo_id_lookup=%s/%s",
981
+ container,
982
+ owner,
983
+ repo,
984
+ )
985
+ return None
986
+
909
987
  from .graphql_validator import REPO_ID_QUERY
910
988
 
911
989
  try:
@@ -919,19 +997,114 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
919
997
  logger.info("PREFLIGHT repo_node_id failed for %s/%s", owner, repo)
920
998
  return None
921
999
 
922
- def _preflight_check(self, node_id: str) -> str | None:
1000
+ def _preflight_cache_get(self, node_id: str) -> tuple[bool, str | None]:
1001
+ """Look up a preflight result in the cache. Returns (hit, value)."""
1002
+ now = time.time()
1003
+ with self._preflight_cache_lock:
1004
+ cached = self._preflight_cache.get(node_id)
1005
+ if cached is None:
1006
+ return False, None
1007
+ value, expiry = cached
1008
+ if expiry <= now:
1009
+ # Expired: drop and miss.
1010
+ self._preflight_cache.pop(node_id, None)
1011
+ return False, None
1012
+ return True, value
1013
+
1014
+ def _preflight_cache_put(self, node_id: str, value: str | None) -> None:
1015
+ """Cache a preflight result with TTL based on positive/negative."""
1016
+ ttl = PREFLIGHT_POSITIVE_CACHE_TTL if value else PREFLIGHT_NEGATIVE_CACHE_TTL
1017
+ expiry = time.time() + ttl
1018
+ with self._preflight_cache_lock:
1019
+ # Bound the cache: drop the entry with the soonest expiry if full.
1020
+ if (
1021
+ len(self._preflight_cache) >= MAX_PREFLIGHT_CACHE_ENTRIES
1022
+ and node_id not in self._preflight_cache
1023
+ ):
1024
+ oldest = min(self._preflight_cache, key=lambda k: self._preflight_cache[k][1])
1025
+ self._preflight_cache.pop(oldest, None)
1026
+ self._preflight_cache[node_id] = (value, expiry)
1027
+
1028
+ def _preflight_check(self, node_id: str, container: str) -> str | None:
923
1029
  """Check which repo a node belongs to.
924
1030
 
925
- Returns "owner/repo" string or None on failure.
1031
+ Returns "owner/repo" string or None on failure / unverifiable.
1032
+
1033
+ Counts uncached upstream queries against the originating container's
1034
+ rate window so misbehaving containers can't burn the host's GitHub
1035
+ quota through preflight traffic. Caches both positive results and
1036
+ definitive negative answers from GitHub with short TTLs so repeated
1037
+ probes for the same ID don't re-query at all. Transient failures
1038
+ (network errors, timeouts, GraphQL-level errors) are NOT cached, to
1039
+ avoid one upstream blip locking out a legitimate node ID for 60s.
1040
+
1041
+ Concurrent misses for the same node id are serialized via a per-id
1042
+ singleflight so a flood of parallel handlers can't fan out into N
1043
+ host-side calls before the first one populates the cache.
926
1044
  """
927
- from .graphql_validator import PREFLIGHT_QUERY, extract_repo_from_preflight
1045
+ hit, value = self._preflight_cache_get(node_id)
1046
+ if hit:
1047
+ return value
1048
+
1049
+ # Singleflight: at most one upstream query per node id at a time.
1050
+ # If another handler is already querying, wait for its result then
1051
+ # re-check the cache. If the cache still misses (transient failure
1052
+ # — those aren't cached), fall through and try again ourselves,
1053
+ # subject to the rate limiter.
1054
+ while True:
1055
+ with self._preflight_inflight_lock:
1056
+ event = self._preflight_inflight.get(node_id)
1057
+ if event is None:
1058
+ event = threading.Event()
1059
+ self._preflight_inflight[node_id] = event
1060
+ is_leader = True
1061
+ else:
1062
+ is_leader = False
1063
+ if is_leader:
1064
+ break
1065
+ # Wait modestly longer than the upstream timeout (30s) so a
1066
+ # stuck leader can't pin us forever.
1067
+ event.wait(timeout=35)
1068
+ hit, value = self._preflight_cache_get(node_id)
1069
+ if hit:
1070
+ return value
1071
+ # Cache miss after the leader finished — leader saw a transient
1072
+ # failure and didn't cache. Loop to take the leader role.
928
1073
 
929
1074
  try:
930
- data = self._github_graphql_query(PREFLIGHT_QUERY, {"id": node_id})
931
- return extract_repo_from_preflight(data)
932
- except Exception:
933
- logger.info("PREFLIGHT check failed for node %s", node_id)
934
- return None
1075
+ if not self.rate_limiter.check(container):
1076
+ logger.info(
1077
+ "PREFLIGHT rate-limited container=%s node_id=%s",
1078
+ container,
1079
+ node_id,
1080
+ )
1081
+ # Don't cache: the rate-limit decision is per-container/
1082
+ # per-window, not a property of the node ID.
1083
+ return None
1084
+
1085
+ from .graphql_validator import PREFLIGHT_QUERY, extract_repo_from_preflight
1086
+
1087
+ try:
1088
+ data = self._github_graphql_query(PREFLIGHT_QUERY, {"id": node_id})
1089
+ except Exception:
1090
+ logger.info("PREFLIGHT check failed for node %s", node_id)
1091
+ # Transient (HTTP error / timeout / network): don't cache.
1092
+ return None
1093
+
1094
+ # Treat GraphQL-level errors as transient too: a 200 with an
1095
+ # `errors` array can be a server hiccup ("Something went wrong",
1096
+ # secondary rate limit, etc.) and shouldn't poison the cache.
1097
+ if isinstance(data, dict) and data.get("errors"):
1098
+ logger.info("PREFLIGHT graphql errors for node %s", node_id)
1099
+ return None
1100
+
1101
+ result = extract_repo_from_preflight(data)
1102
+ self._preflight_cache_put(node_id, result)
1103
+ return result
1104
+ finally:
1105
+ with self._preflight_inflight_lock:
1106
+ self._preflight_inflight.pop(node_id, None)
1107
+ event.set()
935
1108
 
936
1109
  def _forward_to_github(
937
1110
  self,
@@ -991,11 +1164,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
991
1164
  return
992
1165
  # For git or non-followable redirects, return as-is
993
1166
  self.send_response(e.code)
994
- for header, value in e.headers.items():
995
- lower = header.lower()
996
- if lower in ("transfer-encoding", "connection", "keep-alive"):
997
- continue
998
- self.send_header(header, value)
1167
+ self._copy_response_headers(e.headers.items())
999
1168
  self.end_headers()
1000
1169
  body_data = e.read()
1001
1170
  if body_data:
@@ -1035,14 +1204,9 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
1035
1204
  # Pass through 4xx errors from GitHub (auth failures, not found, etc.)
1036
1205
  if 400 <= e.code < 500:
1037
1206
  self.send_response(e.code)
1038
- for header, value in e.headers.items():
1039
- lower = header.lower()
1040
- if lower in ("transfer-encoding", "connection", "keep-alive"):
1041
- continue
1042
- # Strip GitHub's auth-related headers
1043
- if lower == "authorization":
1044
- continue
1045
- self.send_header(header, value)
1207
+ self._copy_response_headers(
1208
+ e.headers.items(), stripped=_STRIPPED_4XX_RESPONSE_HEADERS
1209
+ )
1046
1210
  self.end_headers()
1047
1211
  body_data = e.read()
1048
1212
  if body_data:
@@ -1079,11 +1243,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
1079
1243
 
1080
1244
  # Send response back to client
1081
1245
  self.send_response(resp.status)
1082
- for header, value in resp.getheaders():
1083
- lower = header.lower()
1084
- if lower in ("transfer-encoding", "connection", "keep-alive"):
1085
- continue
1086
- self.send_header(header, value)
1246
+ self._copy_response_headers(resp.getheaders())
1087
1247
  self.end_headers()
1088
1248
 
1089
1249
  # Stream response body
@@ -1119,11 +1279,13 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
1119
1279
  return
1120
1280
 
1121
1281
  self.send_response(status)
1122
- for header, value in headers.items():
1123
- lower = header.lower()
1124
- if lower in ("transfer-encoding", "connection", "keep-alive"):
1125
- continue
1126
- self.send_header(header, value)
1282
+ # Defensive: today _follow_redirect only returns on 2xx (HTTPError is
1283
+ # raised above and converted to 502), but if that ever changes, keep
1284
+ # the same Location-strip rule that direct 4xx pass-through uses.
1285
+ stripped = (
1286
+ _STRIPPED_4XX_RESPONSE_HEADERS if 400 <= status < 500 else _STRIPPED_RESPONSE_HEADERS
1287
+ )
1288
+ self._copy_response_headers(headers, stripped=stripped)
1127
1289
  self.end_headers()
1128
1290
  if body:
1129
1291
  self.wfile.write(body)
@@ -1234,23 +1396,45 @@ def _get_github_token() -> str:
1234
1396
  the stored access token has expired, then reads the (now-current)
1235
1397
  token via ``gh auth token``.
1236
1398
  """
1399
+ import shutil
1237
1400
  import subprocess
1238
1401
 
1402
+ gh_path = shutil.which("gh")
1403
+ if not gh_path:
1404
+ raise RuntimeError(
1405
+ "Cannot find 'gh' CLI on PATH. Install gh (https://cli.github.com) "
1406
+ "and ensure it is on your PATH.\n"
1407
+ f"Current PATH: {os.environ.get('PATH', '(not set)')}"
1408
+ )
1409
+
1239
1410
  # Trigger OAuth refresh (gh auth token alone returns the stale token)
1240
1411
  try:
1241
1412
  subprocess.run(
1242
- ["gh", "auth", "status"],
1413
+ [gh_path, "auth", "status"],
1243
1414
  capture_output=True,
1244
1415
  timeout=15,
1245
1416
  )
1246
- except (FileNotFoundError, subprocess.TimeoutExpired):
1417
+ except subprocess.TimeoutExpired:
1247
1418
  pass # Best-effort; get_host_gh_token may still succeed
1248
1419
 
1249
1420
  from .github_token import get_host_gh_token
1250
1421
 
1251
1422
  token = get_host_gh_token()
1252
1423
  if not token:
1253
- raise RuntimeError("No GitHub token available. Run 'gh auth login' first.")
1424
+ # Try to get more diagnostic info
1425
+ try:
1426
+ result = subprocess.run(
1427
+ [gh_path, "auth", "token"],
1428
+ capture_output=True,
1429
+ text=True,
1430
+ timeout=10,
1431
+ )
1432
+ detail_msg = f"gh auth token exited {result.returncode}"
1433
+ if result.stderr.strip():
1434
+ detail_msg += f": {result.stderr.strip()}"
1435
+ except Exception as e:
1436
+ detail_msg = f"gh auth token failed: {e}"
1437
+ raise RuntimeError(f"No GitHub token available. {detail_msg}\nRun 'gh auth login' first.")
1254
1438
  return token
1255
1439
 
1256
1440