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.
- bubble_agent/__init__.py +6 -0
- bubble_agent/__main__.py +4 -0
- bubble_agent/cli.py +177 -0
- bubble_agent/config.py +123 -0
- bubble_agent/sandbox.py +243 -0
- bubble_agent/workspace.py +88 -0
- bubble_agent-0.1.0.dist-info/METADATA +47 -0
- bubble_agent-0.1.0.dist-info/RECORD +11 -0
- bubble_agent-0.1.0.dist-info/WHEEL +4 -0
- bubble_agent-0.1.0.dist-info/entry_points.txt +2 -0
- bubble_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
bubble_agent/__init__.py
ADDED
bubble_agent/__main__.py
ADDED
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
|
bubble_agent/sandbox.py
ADDED
|
@@ -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
|
+
[](https://github.com/ak1ra-lab/bubble-agent/actions/workflows/publish-to-pypi.yaml)
|
|
24
|
+
[](https://pypi.org/project/bubble-agent/)
|
|
25
|
+
[](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,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.
|