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.
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.claude/CLAUDE.md +3 -1
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/CHANGELOG.md +15 -0
- {dev_bubble-0.7.11/dev_bubble.egg-info → dev_bubble-0.7.17}/PKG-INFO +2 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/README.md +1 -1
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/SPEC.md +14 -28
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/ai.py +22 -12
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/auth_proxy.py +227 -43
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/automation.py +20 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/clean.py +0 -133
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cli.py +203 -86
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/doctor.py +5 -19
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/infrastructure.py +12 -3
- dev_bubble-0.7.17/bubble/commands/internal.py +106 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/lifecycle.py +107 -93
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/list_cmd.py +1 -32
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/settings.py +3 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/status_cmd.py +2 -6
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/config.py +72 -15
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/container_helpers.py +154 -12
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/finalization.py +61 -19
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/github_token.py +153 -75
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/graphql_validator.py +13 -12
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/builder.py +9 -17
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/base.sh +6 -1
- dev_bubble-0.7.17/bubble/images/scripts/python.sh +22 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/gh.sh +21 -1
- dev_bubble-0.7.17/bubble/images/scripts/tools/uv.sh +20 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/lifecycle.py +37 -13
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/provisioning.py +2 -1
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/remote.py +23 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/base.py +15 -1
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/colima.py +106 -50
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/incus.py +76 -20
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/security.py +9 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/setup.py +7 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/tools.py +11 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/vscode.py +107 -59
- {dev_bubble-0.7.11 → dev_bubble-0.7.17/dev_bubble.egg-info}/PKG-INFO +2 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/SOURCES.txt +10 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/conftest.py +1 -3
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_auth_proxy.py +436 -0
- dev_bubble-0.7.17/tests/test_authorized_keys.py +129 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_build_lock.py +20 -5
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_claude_projects_symlink.py +49 -0
- dev_bubble-0.7.17/tests/test_colima.py +187 -0
- dev_bubble-0.7.17/tests/test_editor.py +318 -0
- dev_bubble-0.7.17/tests/test_ephemeral.py +158 -0
- dev_bubble-0.7.17/tests/test_github_security_override.py +314 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_github_token.py +12 -2
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_graphql_validator.py +11 -8
- dev_bubble-0.7.17/tests/test_internal.py +208 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_lifecycle.py +66 -6
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_mounts.py +95 -0
- dev_bubble-0.7.17/tests/test_multi_target.py +55 -0
- dev_bubble-0.7.17/tests/test_reattach_network.py +191 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_remote.py +32 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_status.py +2 -18
- dev_bubble-0.7.17/tests/test_systemd_path.py +26 -0
- dev_bubble-0.7.17/tests/test_token_no_argv_leak.py +161 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_vscode.py +75 -0
- dev_bubble-0.7.11/bubble/images/scripts/python.sh +0 -18
- dev_bubble-0.7.11/bubble/native.py +0 -300
- dev_bubble-0.7.11/tests/test_editor.py +0 -158
- dev_bubble-0.7.11/tests/test_multi_target.py +0 -29
- dev_bubble-0.7.11/tests/test_native.py +0 -198
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/.gitignore +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/LICENSE +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/clone.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/__init__.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/lean.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/lean.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/naming.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/network.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/notices.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/output.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/relay.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/skill.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/target.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/conftest.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/pyproject.toml +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/setup.cfg +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_config.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_hooks.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_network.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_security.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_target.py +0 -0
- {dev_bubble-0.7.11 → dev_bubble-0.7.17}/tests/test_tools.py +0 -0
- {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
|
|
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.
|
|
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> <
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
|
621
|
-
`incus exec` as root — the `user` account has no
|
|
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) |
|
|
@@ -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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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,
|
|
513
|
+
) -> tuple[int, list[tuple[str, str]], bytes]:
|
|
471
514
|
"""Follow a redirect URL with hardened rules.
|
|
472
515
|
|
|
473
|
-
Returns (status_code,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
[
|
|
1413
|
+
[gh_path, "auth", "status"],
|
|
1243
1414
|
capture_output=True,
|
|
1244
1415
|
timeout=15,
|
|
1245
1416
|
)
|
|
1246
|
-
except
|
|
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
|
-
|
|
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
|
|