bubble-agent 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("bubble-agent")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
@@ -0,0 +1,4 @@
1
+ from bubble_agent.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
bubble_agent/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+
12
+ import argcomplete
13
+
14
+ from bubble_agent import __version__
15
+ from bubble_agent.sandbox import (
16
+ build_bubble_args,
17
+ fmt_bubble_cmd,
18
+ make_data_fd,
19
+ patch_etc_profile,
20
+ )
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(message)s",
25
+ stream=sys.stderr,
26
+ )
27
+
28
+
29
+ def create_parser(
30
+ default_config: Path | None = None,
31
+ default_bin: str = "opencode",
32
+ ) -> argparse.ArgumentParser:
33
+ """Build the argument parser.
34
+
35
+ *default_config* and *default_bin* can be overridden via environment
36
+ variables ``BUBBLE_AGENT_CONFIG_FILE`` and ``BUBBLE_AGENT_BIN``.
37
+ """
38
+ if default_config is None:
39
+ default_config = Path(
40
+ os.environ.get(
41
+ "BUBBLE_AGENT_CONFIG_FILE",
42
+ "~/.config/bubble-agent/bubble-agent.conf",
43
+ )
44
+ ).expanduser()
45
+
46
+ parser = argparse.ArgumentParser(
47
+ description="Run your coding agent in a bubble.",
48
+ )
49
+ parser.add_argument(
50
+ "-V",
51
+ "--version",
52
+ action="version",
53
+ version=f"%(prog)s {__version__}",
54
+ )
55
+ parser.add_argument(
56
+ "paths",
57
+ nargs="*",
58
+ help="Directories or .code-workspace files to bind into the sandbox",
59
+ )
60
+ parser.add_argument(
61
+ "-c",
62
+ "--config",
63
+ type=Path,
64
+ default=default_config,
65
+ help="Path to config file (default: %(default)s)",
66
+ )
67
+ parser.add_argument(
68
+ "-b",
69
+ "--bin",
70
+ default=default_bin,
71
+ help="Agent binary to run (default: %(default)s)",
72
+ )
73
+ parser.add_argument(
74
+ "-S",
75
+ "--no-symlink",
76
+ action="store_true",
77
+ help="Do not symlink workspace folders (preserve original tree structure)",
78
+ )
79
+ parser.add_argument(
80
+ "-D",
81
+ "--dry-run",
82
+ action="store_true",
83
+ help="Print bwrap command without executing",
84
+ )
85
+ return parser
86
+
87
+
88
+ def parse_cli(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
89
+ """Split *argv* at ``--`` into bubble-agent options and agent args.
90
+
91
+ Everything before ``--`` is parsed by :func:`create_parser`; everything
92
+ after is forwarded to the agent binary unchanged.
93
+ """
94
+ try:
95
+ split_idx = argv.index("--")
96
+ except ValueError:
97
+ split_idx = len(argv)
98
+
99
+ cli_args = argv[:split_idx]
100
+ agent_args = argv[split_idx + 1 :] if split_idx < len(argv) else []
101
+
102
+ default_config = Path(
103
+ os.environ.get(
104
+ "BUBBLE_AGENT_CONFIG_FILE",
105
+ "~/.config/bubble-agent/bubble-agent.conf",
106
+ )
107
+ ).expanduser()
108
+ default_bin = os.environ.get("BUBBLE_AGENT_BIN", "opencode")
109
+
110
+ parser = create_parser(default_config, default_bin)
111
+ argcomplete.autocomplete(parser)
112
+ opts = parser.parse_args(cli_args)
113
+ return opts, agent_args
114
+
115
+
116
+ def _launch(args: list[list[str]], bin_path: str, agent_args: list[str]) -> None:
117
+ """Feed bwrap options via ``--args <fd>`` and execute the sandbox.
118
+
119
+ Serializes *args* into a NUL-separated pipe for ``--args <fd>``,
120
+ patches ``/etc/profile`` to preserve the sandbox ``PATH``, and runs
121
+ bwrap via :func:`subprocess.run` with explicit ``pass_fds``.
122
+
123
+ Does not return — calls :func:`sys.exit` with bwrap's exit code.
124
+ """
125
+ bwrap_payload = [item for group in args for item in group]
126
+ args_data = b"\0".join(a.encode("utf-8") for a in bwrap_payload) + b"\0"
127
+ args_fd = make_data_fd(args_data)
128
+
129
+ path_value = next(
130
+ (g[2] for g in reversed(args) if g[0] == "--setenv" and g[1] == "PATH"),
131
+ os.environ.get("PATH", ""),
132
+ )
133
+ profile_fd = make_data_fd(patch_etc_profile(path_value))
134
+
135
+ bubble_cmd = [
136
+ "bwrap",
137
+ "--args",
138
+ str(args_fd),
139
+ "--ro-bind-data",
140
+ str(profile_fd),
141
+ "/etc/profile",
142
+ "--",
143
+ bin_path,
144
+ ] + agent_args
145
+
146
+ try:
147
+ result = subprocess.run(bubble_cmd, pass_fds=[args_fd, profile_fd])
148
+ except FileNotFoundError:
149
+ logging.error("bwrap not found in PATH")
150
+ sys.exit(1)
151
+ finally:
152
+ os.close(args_fd)
153
+ os.close(profile_fd)
154
+
155
+ sys.exit(result.returncode)
156
+
157
+
158
+ def main(argv: Sequence[str] | None = None) -> None:
159
+ """Entry point: parse CLI, build sandbox, and launch the agent.
160
+
161
+ On ``--dry-run``, prints the equivalent ``bwrap`` command to stderr and
162
+ exits. Otherwise, serializes sandbox configuration into anonymous pipes
163
+ and delegates execution to :func:`_launch`.
164
+ """
165
+ if argv is None:
166
+ argv = sys.argv[1:]
167
+
168
+ opts, agent_args = parse_cli(list(argv))
169
+ bin_path = shutil.which(opts.bin) or opts.bin
170
+
171
+ args = build_bubble_args(opts)
172
+
173
+ if opts.dry_run:
174
+ logging.info(fmt_bubble_cmd(args, bin_path, agent_args))
175
+ sys.exit(0)
176
+
177
+ _launch(args, bin_path, agent_args)
bubble_agent/config.py ADDED
@@ -0,0 +1,123 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+
6
+ SHELL_VARS = {
7
+ "UID": str(os.getuid()),
8
+ "EUID": str(os.geteuid()),
9
+ "GID": str(os.getgid()),
10
+ "EGID": str(os.getegid()),
11
+ }
12
+
13
+ SHELL_VAR_RE = re.compile(
14
+ r"\$\{(UID|EUID|GID|EGID)\}|\$(UID|EUID|GID|EGID)(?![A-Za-z0-9_])"
15
+ )
16
+
17
+ UNEXPANDED_RE = re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?")
18
+
19
+ BIND_FLAG: dict[str, str] = {
20
+ "bind": "--bind",
21
+ "ro-bind": "--ro-bind",
22
+ "bind-try": "--bind-try",
23
+ "ro-bind-try": "--ro-bind-try",
24
+ "symlink": "--symlink",
25
+ "env": "--setenv",
26
+ "setenv": "--setenv",
27
+ }
28
+
29
+
30
+ def expand_path(s: str) -> str:
31
+ """Expand ``$UID``/``${EUID}``/..., ``$VAR``, ``~`` in *s*.
32
+
33
+ Shell built-in vars (``UID``, ``EUID``, ``GID``, ``EGID``) are
34
+ resolved from the process, then :func:`os.path.expandvars` and
35
+ :func:`os.path.expanduser` handle the rest.
36
+ """
37
+ s = SHELL_VAR_RE.sub(lambda m: SHELL_VARS[m.group(1) or m.group(2)], s)
38
+ return os.path.expandvars(os.path.expanduser(s))
39
+
40
+
41
+ def has_unexpanded(s: str) -> bool:
42
+ """Return ``True`` if *s* appears to contain an unexpanded variable."""
43
+ return "$" in s and UNEXPANDED_RE.search(s) is not None
44
+
45
+
46
+ def load_config(
47
+ conf: Path,
48
+ ) -> tuple[list[list[str]], list[str], list[str]]:
49
+ """Parse a config file and return ``(bind_args, path_prepend, path_append)``.
50
+
51
+ Lines are of the form ``type:source:destination`` (see docs for the
52
+ full list of bind types). ``#`` starts a comment; blank lines are
53
+ ignored. Returns empty lists when *conf* does not exist.
54
+ """
55
+ if not conf.is_file():
56
+ return [], [], []
57
+
58
+ logging.info("Loading binds from: %s", conf)
59
+ logging.info("----------------------------------------")
60
+
61
+ args: list[list[str]] = []
62
+ path_prepend: list[str] = []
63
+ path_append: list[str] = []
64
+ count = 0
65
+
66
+ for line in conf.read_text(encoding="utf-8").splitlines():
67
+ line = line.strip()
68
+ if not line or line.startswith("#"):
69
+ continue
70
+ parts = line.split(":", 2)
71
+ if len(parts) < 2:
72
+ continue
73
+
74
+ typ = parts[0].strip()
75
+ src = parts[1].strip()
76
+ dst = parts[2].strip() if len(parts) > 2 else ""
77
+
78
+ if typ in ("workspace", "workspace-path"):
79
+ logging.warning(
80
+ "[%s] config-based workspace is deprecated, use CLI args", typ
81
+ )
82
+ continue
83
+
84
+ if typ in ("path-prepend", "path-append"):
85
+ src = expand_path(src)
86
+ if has_unexpanded(src):
87
+ logging.warning("[%s] SKIP: unexpanded variable in %s", typ, src)
88
+ continue
89
+ logging.info("[%s] %s", typ, src)
90
+ if typ == "path-prepend":
91
+ path_prepend.append(src)
92
+ else:
93
+ path_append.append(src)
94
+ count += 1
95
+ continue
96
+
97
+ flag = BIND_FLAG.get(typ)
98
+ if not flag:
99
+ logging.warning("Unknown bind type: %s", typ)
100
+ continue
101
+
102
+ src = expand_path(src)
103
+ dst = expand_path(dst)
104
+
105
+ if has_unexpanded(src) or has_unexpanded(dst):
106
+ logging.warning("[%s] SKIP: unexpanded variable in %s:%s", typ, src, dst)
107
+ continue
108
+
109
+ if typ in ("bind", "ro-bind") and not os.path.exists(src):
110
+ logging.warning("[%s] SKIP: %s (not found)", typ, src)
111
+ continue
112
+
113
+ if typ in ("env", "setenv"):
114
+ logging.info("[env] %s=%s", src, dst)
115
+ else:
116
+ logging.info("[%s] %s -> %s", typ, src, dst)
117
+ args.append([flag, src, dst])
118
+ count += 1
119
+
120
+ logging.info("----------------------------------------")
121
+ logging.info("Total custom binds loaded: %d", count)
122
+ logging.info("")
123
+ return args, path_prepend, path_append
@@ -0,0 +1,243 @@
1
+ import argparse
2
+ import getpass
3
+ import logging
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from bubble_agent.config import expand_path, load_config
9
+ from bubble_agent.workspace import parse_workspace, ws_folder_paths
10
+
11
+
12
+ def fmt_bubble_cmd(
13
+ groups: list[list[str]], bin_path: str, agent_args: list[str]
14
+ ) -> str:
15
+ """Format a ``bwrap`` command-line string for ``--dry-run`` output.
16
+
17
+ Each group in *groups* is joined with spaces; groups are separated by
18
+ `` \\\n `` for readability.
19
+ """
20
+ items: list[str] = ["bwrap"]
21
+ for g in groups:
22
+ items.append(" ".join(g))
23
+ items.append("--")
24
+ items.append(bin_path)
25
+ items.extend(agent_args)
26
+ return " \\\n ".join(items)
27
+
28
+
29
+ def make_data_fd(data: bytes) -> int:
30
+ """Write *data* into a pipe and return the read-end file descriptor.
31
+
32
+ The caller is responsible for closing the returned fd.
33
+ Intended for passing arbitrary payloads to subprocesses via ``pass_fds``
34
+ (e.g. bwrap ``--args <fd>``).
35
+ """
36
+ r_fd, w_fd = os.pipe()
37
+ try:
38
+ while data:
39
+ try:
40
+ n = os.write(w_fd, data)
41
+ except InterruptedError:
42
+ continue
43
+ data = data[n:]
44
+ finally:
45
+ os.close(w_fd)
46
+ return r_fd
47
+
48
+
49
+ _PATH_BLOCK_RE = re.compile(
50
+ r'if\s+\[\s*"\$\(id\s+-u\)"[^;]*;\s*then\b'
51
+ r".*?"
52
+ r"\bfi\b\s*\n\s*export\s+PATH\s*\n?",
53
+ re.DOTALL,
54
+ )
55
+
56
+
57
+ def patch_etc_profile(custom_path: str) -> bytes:
58
+ """Return a patched ``/etc/profile`` that keeps *custom_path* in effect.
59
+
60
+ The standard ``id -u`` PATH-initialization block is removed and
61
+ *custom_path* is appended at the end so it always wins. All other
62
+ content (``/etc/bash.bashrc``, ``/etc/profile.d/*``, …) is preserved.
63
+ """
64
+ try:
65
+ original = Path("/etc/profile").read_text()
66
+ except OSError:
67
+ original = ""
68
+
69
+ patched = _PATH_BLOCK_RE.sub("", original)
70
+ patched = patched.rstrip("\n") + f'\nPATH="{custom_path}"\nexport PATH\n'
71
+ return patched.encode()
72
+
73
+
74
+ def resolv_conf_args() -> list[list[str]]:
75
+ """Bind-resolve ``/etc/resolv.conf`` when it is a symlink.
76
+
77
+ Returns the ``--ro-bind`` entry for the parent directory of the
78
+ symlink target, or an empty list when nothing needs to be done.
79
+ """
80
+ resolv = Path("/etc/resolv.conf")
81
+ if not resolv.is_symlink():
82
+ return []
83
+ try:
84
+ real = resolv.resolve(strict=True)
85
+ except OSError:
86
+ return []
87
+ if real.parent.is_dir():
88
+ logging.info("resolv.conf symlink: /etc/resolv.conf -> %s", real)
89
+ return [["--ro-bind", str(real.parent), str(real.parent)]]
90
+ return []
91
+
92
+
93
+ def build_bubble_args(opts: argparse.Namespace) -> list[list[str]]:
94
+ """Build the full list of bwrap argument groups from CLI *opts*.
95
+
96
+ Combines fixed sandbox setup (namespaces, mounts, env vars) with
97
+ entries from the config file and positional path arguments.
98
+ """
99
+ home = os.environ.get("HOME", str(Path.home()))
100
+ user = os.environ.get("USER") or os.environ.get("LOGNAME") or getpass.getuser()
101
+ logname = os.environ.get("LOGNAME") or user
102
+ shell = os.environ.get("SHELL", "/bin/sh")
103
+ uid = os.getuid()
104
+
105
+ args: list[list[str]] = []
106
+
107
+ args.append(["--unshare-pid"])
108
+ args.append(["--die-with-parent"])
109
+ args.append(["--new-session"])
110
+
111
+ args.append(["--clearenv"])
112
+ args.extend(
113
+ [
114
+ ["--setenv", "HOME", home],
115
+ ["--setenv", "USER", user],
116
+ ["--setenv", "LOGNAME", logname],
117
+ ["--setenv", "TERM", os.environ.get("TERM", "xterm-256color")],
118
+ ["--setenv", "LANG", os.environ.get("LANG", "en_US.UTF-8")],
119
+ ["--setenv", "SHELL", shell],
120
+ ]
121
+ )
122
+
123
+ args.append(["--share-net"])
124
+ args.append(["--dev", "/dev"])
125
+
126
+ args.extend(
127
+ [
128
+ ["--ro-bind", "/usr", "/usr"],
129
+ ["--ro-bind", "/etc", "/etc"],
130
+ ["--ro-bind-try", "/lib", "/lib"],
131
+ ["--ro-bind-try", "/lib64", "/lib64"],
132
+ ["--ro-bind-try", "/lib32", "/lib32"],
133
+ ["--ro-bind-try", "/sys", "/sys"],
134
+ ]
135
+ )
136
+
137
+ args.append(["--symlink", "usr/bin", "/bin"])
138
+ args.append(["--symlink", "usr/sbin", "/sbin"])
139
+
140
+ args.append(["--proc", "/proc"])
141
+ args.append(["--tmpfs", "/tmp"])
142
+
143
+ args.append(["--tmpfs", "/run"])
144
+ args.append(["--dir", f"/run/user/{uid}"])
145
+ args.append(["--setenv", "XDG_RUNTIME_DIR", f"/run/user/{uid}"])
146
+
147
+ args.append(["--dir", "/var"])
148
+ args.append(["--symlink", "../tmp", "var/tmp"])
149
+
150
+ args.extend(resolv_conf_args())
151
+
152
+ config_groups, path_prepend, path_append = load_config(opts.config)
153
+
154
+ explicit_path = next(
155
+ (
156
+ g[2]
157
+ for g in reversed(config_groups)
158
+ if g[0] == "--setenv" and g[1] == "PATH"
159
+ ),
160
+ None,
161
+ )
162
+
163
+ if explicit_path is not None:
164
+ config_groups = [
165
+ g for g in config_groups if not (g[0] == "--setenv" and g[1] == "PATH")
166
+ ]
167
+ base_parts = explicit_path.split(":") if explicit_path else []
168
+ all_paths = list(path_prepend) + base_parts + list(path_append)
169
+ else:
170
+ base_path = os.environ.get("PATH", "")
171
+ all_paths = (
172
+ list(path_prepend)
173
+ + (base_path.split(":") if base_path else [])
174
+ + list(path_append)
175
+ )
176
+
177
+ seen: set[str] = set()
178
+ merged: list[str] = []
179
+ for p in all_paths:
180
+ if p and p not in seen:
181
+ seen.add(p)
182
+ merged.append(p)
183
+ path_value = ":".join(merged)
184
+ args.append(["--setenv", "PATH", path_value])
185
+
186
+ args.extend(config_groups)
187
+
188
+ dir_paths: list[str] = []
189
+ ws_files: list[str] = []
190
+
191
+ for p in opts.paths or []:
192
+ expanded = expand_path(p)
193
+ if expanded.endswith(".code-workspace"):
194
+ if os.path.isfile(expanded):
195
+ ws_files.append(expanded)
196
+ else:
197
+ logging.warning(
198
+ "[path] %s looks like a workspace file but "
199
+ "is not a regular file, treating as directory",
200
+ expanded,
201
+ )
202
+ dir_paths.append(expanded)
203
+ else:
204
+ dir_paths.append(expanded)
205
+
206
+ for dp in dir_paths:
207
+ args.append(["--bind-try", dp, dp])
208
+
209
+ if ws_files:
210
+ if opts.no_symlink:
211
+ for ws in ws_files:
212
+ args.extend(parse_workspace(ws, "", no_symlink=True))
213
+ args.append(["--dir", home])
214
+
215
+ all_folders: list[Path] = []
216
+ for ws in ws_files:
217
+ all_folders.extend(ws_folder_paths(ws))
218
+ chdir = (
219
+ os.path.commonpath([str(f) for f in all_folders])
220
+ if all_folders
221
+ else str(Path(ws_files[0]).parent)
222
+ )
223
+ args.append(["--chdir", chdir])
224
+ else:
225
+ ws_dest = expand_path("~/workspace")
226
+ args.append(["--tmpfs", ws_dest])
227
+ args.append(["--dir", home])
228
+ for ws in ws_files:
229
+ args.extend(parse_workspace(ws, ws_dest))
230
+ args.append(["--chdir", ws_dest])
231
+ elif dir_paths:
232
+ args.append(["--dir", home])
233
+ args.append(["--chdir", dir_paths[0]])
234
+ if opts.no_symlink:
235
+ logging.warning("--no-symlink has no effect without a .code-workspace file")
236
+ else:
237
+ pwd = str(Path.cwd())
238
+ args.append(["--dir", home])
239
+ args.append(["--bind", pwd, pwd])
240
+ args.append(["--chdir", pwd])
241
+ logging.info("[pwd] auto-binding cwd: %s", pwd)
242
+
243
+ return args
@@ -0,0 +1,88 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from bubble_agent.config import expand_path
6
+
7
+
8
+ def parse_workspace(
9
+ ws_file: str, dest: str, *, no_symlink: bool = False
10
+ ) -> list[list[str]]:
11
+ """Parse a ``.code-workspace`` file into bwrap bind/symlink groups.
12
+
13
+ Binds each folder at its real path and, unless *no_symlink* is set,
14
+ creates a symlink under *dest* with the folder name. Duplicate folder
15
+ names are disambiguated with a parent-directory prefix.
16
+ """
17
+ f = Path(ws_file)
18
+ if not f.is_file():
19
+ logging.warning("Workspace file not found: %s", f)
20
+ return []
21
+ try:
22
+ data = json.loads(f.read_text(encoding="utf-8"))
23
+ except (json.JSONDecodeError, OSError) as exc:
24
+ logging.warning("Failed to parse workspace file %s: %s", f, exc)
25
+ return []
26
+ folders = data.get("folders")
27
+ if not folders:
28
+ logging.warning("Workspace file %s has no 'folders'", f)
29
+ return []
30
+
31
+ ws_root = Path(expand_path(dest)) if dest else Path()
32
+ result: list[list[str]] = []
33
+
34
+ seen: set[str] = set()
35
+ for folder in folders:
36
+ raw = folder.get("path", "")
37
+ if not raw:
38
+ continue
39
+ path = Path(raw)
40
+ if not path.is_absolute():
41
+ path = f.parent / path
42
+ real = path.expanduser().resolve()
43
+ if no_symlink:
44
+ logging.info("[workspace] %s (no-symlink)", real)
45
+ result.append(["--bind-try", str(real), str(real)])
46
+ continue
47
+ name = folder.get("name") or real.name
48
+ if name in seen:
49
+ parent_name = real.parent.name or "root"
50
+ name = f"{parent_name}_{name}"
51
+ if name in seen:
52
+ suffix = 2
53
+ while f"{name}_{suffix}" in seen:
54
+ suffix += 1
55
+ name = f"{name}_{suffix}"
56
+ seen.add(name)
57
+ logging.info("[workspace] %s -> %s/%s", real, ws_root, name)
58
+ result.append(["--bind-try", str(real), str(real)])
59
+ result.append(["--symlink", str(real), f"{ws_root}/{name}"])
60
+ return result
61
+
62
+
63
+ def ws_folder_paths(ws_file: str) -> list[Path]:
64
+ """Return the resolved folder paths from a ``.code-workspace`` file.
65
+
66
+ Relative paths are resolved against the workspace file's parent
67
+ directory. Each path goes through ``.expanduser().resolve()``.
68
+ """
69
+ f = Path(ws_file)
70
+ if not f.is_file():
71
+ return []
72
+ try:
73
+ data = json.loads(f.read_text(encoding="utf-8"))
74
+ except (json.JSONDecodeError, OSError):
75
+ return []
76
+ folders = data.get("folders")
77
+ if not folders:
78
+ return []
79
+ result: list[Path] = []
80
+ for folder in folders:
81
+ raw = folder.get("path", "")
82
+ if not raw:
83
+ continue
84
+ path = Path(raw)
85
+ if not path.is_absolute():
86
+ path = f.parent / path
87
+ result.append(path.expanduser().resolve())
88
+ return result
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: bubble-agent
3
+ Version: 0.1.0
4
+ Summary: Run your coding agent in a bubble.
5
+ Project-URL: Homepage, https://github.com/ak1ra-lab/bubble-agent
6
+ Project-URL: Repository, https://github.com/ak1ra-lab/bubble-agent
7
+ Project-URL: Documentation, https://ak1ra-lab.github.io/bubble-agent/
8
+ Author-email: ak1ra <git@ak1ra.xyz>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: argcomplete>=3.6.3
19
+ Description-Content-Type: text/markdown
20
+
21
+ # bubble-agent
22
+
23
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ak1ra-lab/bubble-agent/.github%2Fworkflows%2Fpublish-to-pypi.yaml)](https://github.com/ak1ra-lab/bubble-agent/actions/workflows/publish-to-pypi.yaml)
24
+ [![PyPI - Version](https://img.shields.io/pypi/v/bubble-agent)](https://pypi.org/project/bubble-agent/)
25
+ [![Docs](https://img.shields.io/badge/docs-online-0a7ea4)](https://ak1ra-lab.github.io/bubble-agent/)
26
+
27
+ > This project is inspired by [cyunrei/opencode-bwrap](https://github.com/cyunrei/opencode-bwrap).
28
+
29
+ Run your coding agent in a bubble.
30
+
31
+ Uses [bubblewrap](https://github.com/containers/bubblewrap) to sandbox coding agents with configurable bind mounts and virtual workspace (`.code-workspace`) support. Sandbox arguments are passed via ``--args <fd>`` for clean process listings, and ``/etc/profile`` is automatically patched to prevent login shells from resetting the custom ``PATH``.
32
+
33
+ By default, the sandbox wraps [opencode](https://github.com/anomalyco/opencode). Use `--bin` to run a different coding agent.
34
+
35
+ ## Quick Start
36
+
37
+ ```shell
38
+ git clone https://github.com/ak1ra-lab/bubble-agent.git
39
+ cd bubble-agent
40
+ just install
41
+
42
+ bubble-agent # runs opencode by default
43
+ bubble-agent ~/my-project # work in a specific directory
44
+ bubble-agent --dry-run # preview the bwrap command
45
+ ```
46
+
47
+ See [Documentation](https://ak1ra-lab.github.io/bubble-agent/) for full usage, configuration, and workspace features.
@@ -0,0 +1,11 @@
1
+ bubble_agent/__init__.py,sha256=FxxSOAmqVmwoL-VcKQGr3dJowovmHbR2GOKASUCYZzg,164
2
+ bubble_agent/__main__.py,sha256=QAjLEVNspn3OzkvhoESNzPDt5hZlHAbLsBQjzhWJz0w,73
3
+ bubble_agent/cli.py,sha256=nHUBEIDTfhbgTAdMiUdsVonSoXKDObKzeKa-N8_OKPI,4908
4
+ bubble_agent/config.py,sha256=NAq596qBIVsnJNIUbTDIp4hs1AT9yKIAaMA_OIv6D_k,3766
5
+ bubble_agent/sandbox.py,sha256=fiPdIh_iMmNTVagqKBr9MANsWZ9WTsCTRPjJwOxP5EM,7530
6
+ bubble_agent/workspace.py,sha256=h31dn0ptkacPKa--NmLBozfXhwNEl3aUP57qqSXAoXs,2905
7
+ bubble_agent-0.1.0.dist-info/METADATA,sha256=naXO6owU6CkJ1wXlIsHuPJ2wuXPqV1dtKrDXIyMRGIE,2305
8
+ bubble_agent-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ bubble_agent-0.1.0.dist-info/entry_points.txt,sha256=YJ09ueGbKGj9pM5gX-IdCCYAbpR40x0mKAYlwpNoO_g,55
10
+ bubble_agent-0.1.0.dist-info/licenses/LICENSE,sha256=YHzY01v-48KH_ZZhuZXvAtJ7xrfmVk5RiNU-r44LVeU,1066
11
+ bubble_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bubble-agent = bubble_agent.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ak1ra-lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.