treebox 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.
- treebox/__init__.py +8 -0
- treebox/assets/container/Dockerfile +103 -0
- treebox/assets/container/allowed-domains.sh +25 -0
- treebox/assets/container/container.json +27 -0
- treebox/assets/container/firewall.json +4 -0
- treebox/assets/container/init-firewall.sh +142 -0
- treebox/assets/container/post-create.sh +48 -0
- treebox/assets/pre-push +27 -0
- treebox/assets.py +69 -0
- treebox/cli.py +1061 -0
- treebox/config.py +118 -0
- treebox/ecosystems.py +222 -0
- treebox/git.py +528 -0
- treebox/locking.py +49 -0
- treebox/models.py +88 -0
- treebox/names.py +166 -0
- treebox/output.py +531 -0
- treebox/provision.py +382 -0
- treebox/py.typed +0 -0
- treebox/resolve.py +65 -0
- treebox/runners/__init__.py +19 -0
- treebox/runners/base.py +73 -0
- treebox/runners/docker.py +688 -0
- treebox/runners/host.py +110 -0
- treebox/state.py +57 -0
- treebox/system.py +43 -0
- treebox-0.1.0.dist-info/METADATA +239 -0
- treebox-0.1.0.dist-info/RECORD +31 -0
- treebox-0.1.0.dist-info/WHEEL +4 -0
- treebox-0.1.0.dist-info/entry_points.txt +2 -0
- treebox-0.1.0.dist-info/licenses/LICENSE +73 -0
treebox/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""treebox: isolated, ready-to-run git worktrees for AI coding agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("treebox")
|
|
7
|
+
except PackageNotFoundError: # not installed (e.g. running from a bare source tree)
|
|
8
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
FROM python:3.14.6-slim
|
|
2
|
+
|
|
3
|
+
ARG TZ=UTC
|
|
4
|
+
ARG USER_UID=1000
|
|
5
|
+
ARG USER_GID=1000
|
|
6
|
+
ARG USERNAME=agent
|
|
7
|
+
ARG NODE_MAJOR=22
|
|
8
|
+
ARG CODEX_CLI_VERSION=latest
|
|
9
|
+
ARG CLAUDE_CODE_VERSION=latest
|
|
10
|
+
ARG GIT_DELTA_VERSION=0.18.2
|
|
11
|
+
|
|
12
|
+
ENV TZ="$TZ"
|
|
13
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
14
|
+
|
|
15
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
16
|
+
ca-certificates curl wget gnupg2 less git procps sudo fzf zsh man-db \
|
|
17
|
+
unzip zip tar gzip bzip2 xz-utils nano vim jq yq rsync ripgrep fd-find \
|
|
18
|
+
iptables ipset iproute2 dnsutils aggregate openssh-client socat \
|
|
19
|
+
gcc g++ make cmake pkg-config python3-dev python3-venv bubblewrap \
|
|
20
|
+
&& curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
|
|
21
|
+
&& mkdir -p -m 755 /etc/apt/keyrings \
|
|
22
|
+
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
23
|
+
| dd of=/etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
|
24
|
+
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
|
25
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
26
|
+
> /etc/apt/sources.list.d/github-cli.list \
|
|
27
|
+
&& apt-get update && apt-get install -y --no-install-recommends nodejs gh \
|
|
28
|
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
29
|
+
|
|
30
|
+
RUN ARCH=$(dpkg --print-architecture) && \
|
|
31
|
+
wget -q "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
|
32
|
+
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
|
33
|
+
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
|
34
|
+
|
|
35
|
+
RUN ARCH=$(dpkg --print-architecture) && \
|
|
36
|
+
case "$ARCH" in \
|
|
37
|
+
amd64) AWS_ARCH=x86_64 ;; \
|
|
38
|
+
arm64) AWS_ARCH=aarch64 ;; \
|
|
39
|
+
*) echo "unsupported arch $ARCH" && exit 1 ;; \
|
|
40
|
+
esac && \
|
|
41
|
+
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${AWS_ARCH}.zip" -o /tmp/awscliv2.zip && \
|
|
42
|
+
unzip -q /tmp/awscliv2.zip -d /tmp && \
|
|
43
|
+
/tmp/aws/install && \
|
|
44
|
+
rm -rf /tmp/awscliv2.zip /tmp/aws
|
|
45
|
+
|
|
46
|
+
RUN if ! getent group "${USER_GID}" >/dev/null; then groupadd --gid "${USER_GID}" "${USERNAME}"; fi && \
|
|
47
|
+
useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/zsh "${USERNAME}" && \
|
|
48
|
+
echo "${USERNAME} ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/${USERNAME}-firewall && \
|
|
49
|
+
chmod 0440 /etc/sudoers.d/${USERNAME}-firewall
|
|
50
|
+
|
|
51
|
+
RUN mkdir -p /workspace /home/${USERNAME}/.codex /home/${USERNAME}/.claude /home/${USERNAME}/.aws /commandhistory && \
|
|
52
|
+
chown -R ${USERNAME}:${USER_GID} /workspace /home/${USERNAME} /commandhistory
|
|
53
|
+
|
|
54
|
+
RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
|
55
|
+
>> /home/${USERNAME}/.bashrc && \
|
|
56
|
+
echo "export HISTFILE=/commandhistory/.zsh_history" \
|
|
57
|
+
>> /home/${USERNAME}/.zshrc && \
|
|
58
|
+
touch /commandhistory/.bash_history /commandhistory/.zsh_history && \
|
|
59
|
+
chown -R ${USERNAME}:${USER_GID} /commandhistory /home/${USERNAME}/.bashrc /home/${USERNAME}/.zshrc
|
|
60
|
+
|
|
61
|
+
USER ${USERNAME}
|
|
62
|
+
|
|
63
|
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
64
|
+
ENV PATH="/home/${USERNAME}/.local/bin:${PATH}"
|
|
65
|
+
|
|
66
|
+
USER root
|
|
67
|
+
RUN npm install -g "@openai/codex@${CODEX_CLI_VERSION}" && codex --version
|
|
68
|
+
|
|
69
|
+
USER ${USERNAME}
|
|
70
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} && claude --version
|
|
71
|
+
|
|
72
|
+
USER root
|
|
73
|
+
|
|
74
|
+
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
|
75
|
+
RUN npm install -g @playwright/cli playwright && \
|
|
76
|
+
mkdir -p /ms-playwright && \
|
|
77
|
+
npx playwright install-deps chromium && \
|
|
78
|
+
playwright-cli install-browser && \
|
|
79
|
+
chown -R ${USERNAME}:${USER_GID} /ms-playwright && \
|
|
80
|
+
chmod -R u+rwX,go+rX /ms-playwright && \
|
|
81
|
+
CHROMIUM_BIN="$(find /ms-playwright -type f -name chrome -path '*chromium-*' | head -1)" && \
|
|
82
|
+
if [ -z "$CHROMIUM_BIN" ]; then echo "treebox: no Chromium binary found under /ms-playwright" >&2; ls -R /ms-playwright >&2; exit 1; fi && \
|
|
83
|
+
mkdir -p /opt/google/chrome && \
|
|
84
|
+
ln -sfn "$CHROMIUM_BIN" /opt/google/chrome/chrome && \
|
|
85
|
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
86
|
+
|
|
87
|
+
# Setup hooks are baked into the image, never run from the mounted worktree, so a
|
|
88
|
+
# boxed agent cannot edit what runs at container setup.
|
|
89
|
+
COPY init-firewall.sh /usr/local/bin/init-firewall.sh
|
|
90
|
+
COPY allowed-domains.sh /usr/local/share/allowed-domains.sh
|
|
91
|
+
COPY post-create.sh /usr/local/bin/post-create.sh
|
|
92
|
+
RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/post-create.sh
|
|
93
|
+
|
|
94
|
+
USER ${USERNAME}
|
|
95
|
+
|
|
96
|
+
ENV SHELL=/bin/zsh
|
|
97
|
+
ENV EDITOR=nano
|
|
98
|
+
ENV VISUAL=nano
|
|
99
|
+
ENV CODEX_HOME=/home/${USERNAME}/.codex
|
|
100
|
+
ENV CLAUDE_CONFIG_DIR=/home/${USERNAME}/.claude
|
|
101
|
+
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
|
102
|
+
|
|
103
|
+
WORKDIR /workspace
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# Domains allowed through the container firewall (sourced by init-firewall.sh).
|
|
3
|
+
# shellcheck disable=SC2034 # consumed by init-firewall.sh after sourcing
|
|
4
|
+
ALLOWED_DOMAINS=(
|
|
5
|
+
api.anthropic.com
|
|
6
|
+
sentry.io
|
|
7
|
+
statsig.anthropic.com
|
|
8
|
+
statsig.com
|
|
9
|
+
|
|
10
|
+
api.openai.com
|
|
11
|
+
chatgpt.com
|
|
12
|
+
auth.openai.com
|
|
13
|
+
cdn.openai.com
|
|
14
|
+
|
|
15
|
+
pypi.org
|
|
16
|
+
files.pythonhosted.org
|
|
17
|
+
pypi.python.org
|
|
18
|
+
astral.sh
|
|
19
|
+
|
|
20
|
+
registry.npmjs.org
|
|
21
|
+
nodejs.org
|
|
22
|
+
deb.nodesource.com
|
|
23
|
+
|
|
24
|
+
cdn.playwright.dev
|
|
25
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"build": {
|
|
3
|
+
"dockerfile": "Dockerfile",
|
|
4
|
+
"args": {
|
|
5
|
+
"TZ": "UTC",
|
|
6
|
+
"CODEX_CLI_VERSION": "latest",
|
|
7
|
+
"CLAUDE_CODE_VERSION": "latest"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"user": "agent",
|
|
11
|
+
"mounts": [
|
|
12
|
+
"type=volume,source=treebox-shellhistory-${workspaceName},target=/commandhistory"
|
|
13
|
+
],
|
|
14
|
+
"env": {
|
|
15
|
+
"CODEX_HOME": "/home/agent/.codex",
|
|
16
|
+
"CLAUDE_CONFIG_DIR": "/home/agent/.claude",
|
|
17
|
+
"HTTP_PROXY": "",
|
|
18
|
+
"HTTPS_PROXY": "",
|
|
19
|
+
"ALL_PROXY": "",
|
|
20
|
+
"NO_PROXY": "",
|
|
21
|
+
"http_proxy": "",
|
|
22
|
+
"https_proxy": "",
|
|
23
|
+
"all_proxy": "",
|
|
24
|
+
"no_proxy": ""
|
|
25
|
+
},
|
|
26
|
+
"postCreate": "bash /usr/local/bin/post-create.sh"
|
|
27
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
IFS=$'\n\t'
|
|
4
|
+
|
|
5
|
+
# post-create.sh refuses to touch the (untrusted) workspace until this exists;
|
|
6
|
+
# it is only ever created after a fully successful lockdown (see the end).
|
|
7
|
+
READY_FLAG=/run/treebox-firewall-ready
|
|
8
|
+
|
|
9
|
+
# The runner execs this BOTH at setup (so default-deny egress exists before any
|
|
10
|
+
# workspace-derived code runs in post-create.sh) and on every container restart
|
|
11
|
+
# (rules don't survive a restart). When this boot already locked down
|
|
12
|
+
# successfully, skip the rebuild rather than tearing live rules down
|
|
13
|
+
# mid-session. Requires the flag AND the live policy: policy DROP alone also
|
|
14
|
+
# matches the fail_closed end state, and the flag alone could be stale if /run
|
|
15
|
+
# survived a restart. Placed before the trap so a clean skip cannot trip it.
|
|
16
|
+
if [[ -f "$READY_FLAG" ]] && iptables -S OUTPUT 2>/dev/null | grep -q '^-P OUTPUT DROP'; then
|
|
17
|
+
echo "Firewall already active; skipping reconfiguration."
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
# A rebuild is starting: whatever a previous boot left behind, the firewall is
|
|
21
|
+
# not ready until this run completes.
|
|
22
|
+
rm -f "$READY_FLAG"
|
|
23
|
+
|
|
24
|
+
# Fail closed: if setup aborts for any reason, drop all traffic rather than
|
|
25
|
+
# leaving the container's inherited default-ACCEPT policies in place.
|
|
26
|
+
fail_closed() {
|
|
27
|
+
local status=$?
|
|
28
|
+
if [[ "$status" -ne 0 ]]; then
|
|
29
|
+
iptables -P INPUT DROP
|
|
30
|
+
iptables -P FORWARD DROP
|
|
31
|
+
iptables -P OUTPUT DROP
|
|
32
|
+
iptables -F
|
|
33
|
+
if ip6tables -L INPUT >/dev/null 2>&1; then
|
|
34
|
+
ip6tables -P INPUT DROP
|
|
35
|
+
ip6tables -P FORWARD DROP
|
|
36
|
+
ip6tables -P OUTPUT DROP
|
|
37
|
+
ip6tables -F
|
|
38
|
+
fi
|
|
39
|
+
echo "ERROR: firewall setup failed; all traffic dropped" >&2
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
trap fail_closed EXIT
|
|
43
|
+
|
|
44
|
+
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
|
|
45
|
+
DNS_SERVERS=$(awk '/^nameserver/ {print $2}' /etc/resolv.conf | grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || true)
|
|
46
|
+
|
|
47
|
+
# Fetch everything that needs open egress before locking down.
|
|
48
|
+
echo "Fetching GitHub IP ranges..."
|
|
49
|
+
gh_ranges=$(curl -s https://api.github.com/meta)
|
|
50
|
+
[[ -n "$gh_ranges" ]] || { echo "ERROR: Failed to fetch GitHub IP ranges"; exit 1; }
|
|
51
|
+
echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null
|
|
52
|
+
|
|
53
|
+
DOMAINS_FILE=/usr/local/share/allowed-domains.sh
|
|
54
|
+
[[ -f "$DOMAINS_FILE" ]] || { echo "ERROR: $DOMAINS_FILE missing"; exit 1; }
|
|
55
|
+
# shellcheck source=src/treebox/assets/container/allowed-domains.sh
|
|
56
|
+
source "$DOMAINS_FILE"
|
|
57
|
+
[[ ${#ALLOWED_DOMAINS[@]} -gt 0 ]] || { echo "ERROR: ALLOWED_DOMAINS is empty"; exit 1; }
|
|
58
|
+
|
|
59
|
+
# Default-deny before any rules are rebuilt: flushing alone would leave the
|
|
60
|
+
# inherited ACCEPT policies in place, so an abort mid-setup would fail open.
|
|
61
|
+
iptables -P INPUT DROP
|
|
62
|
+
iptables -P FORWARD DROP
|
|
63
|
+
iptables -P OUTPUT DROP
|
|
64
|
+
iptables -F
|
|
65
|
+
iptables -X
|
|
66
|
+
iptables -t nat -F
|
|
67
|
+
iptables -t nat -X
|
|
68
|
+
iptables -t mangle -F
|
|
69
|
+
iptables -t mangle -X
|
|
70
|
+
ipset destroy allowed-domains 2>/dev/null || true
|
|
71
|
+
|
|
72
|
+
# The allowlist is IPv4-only, so IPv6 would bypass it entirely: drop all
|
|
73
|
+
# IPv6 traffic except loopback.
|
|
74
|
+
if ip6tables -L INPUT >/dev/null 2>&1; then
|
|
75
|
+
ip6tables -P INPUT DROP
|
|
76
|
+
ip6tables -P FORWARD DROP
|
|
77
|
+
ip6tables -P OUTPUT DROP
|
|
78
|
+
ip6tables -F
|
|
79
|
+
ip6tables -X
|
|
80
|
+
ip6tables -A INPUT -i lo -j ACCEPT
|
|
81
|
+
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
82
|
+
else
|
|
83
|
+
echo "WARN: ip6tables unavailable; assuming no IPv6 stack"
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
if [[ -n "$DOCKER_DNS_RULES" ]]; then
|
|
87
|
+
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
|
|
88
|
+
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
|
|
89
|
+
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
iptables -A INPUT -i lo -j ACCEPT
|
|
93
|
+
iptables -A OUTPUT -o lo -j ACCEPT
|
|
94
|
+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
95
|
+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
96
|
+
|
|
97
|
+
# DNS only to the configured resolvers; docker's embedded resolver
|
|
98
|
+
# (127.0.0.11) is covered by the restored NAT rules plus the loopback allow.
|
|
99
|
+
while read -r resolver; do
|
|
100
|
+
[[ -n "$resolver" ]] || continue
|
|
101
|
+
iptables -A OUTPUT -p udp --dport 53 -d "$resolver" -j ACCEPT
|
|
102
|
+
iptables -A OUTPUT -p tcp --dport 53 -d "$resolver" -j ACCEPT
|
|
103
|
+
done <<< "$DNS_SERVERS"
|
|
104
|
+
|
|
105
|
+
ipset create allowed-domains hash:net
|
|
106
|
+
|
|
107
|
+
while read -r cidr; do
|
|
108
|
+
[[ "$cidr" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$ ]] || { echo "ERROR: Invalid GitHub CIDR: $cidr"; exit 1; }
|
|
109
|
+
ipset add -exist allowed-domains "$cidr"
|
|
110
|
+
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
|
|
111
|
+
|
|
112
|
+
for domain in "${ALLOWED_DOMAINS[@]}"; do
|
|
113
|
+
echo "Resolving $domain..."
|
|
114
|
+
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
|
115
|
+
if [[ -z "$ips" ]]; then
|
|
116
|
+
echo "WARN: Failed to resolve $domain; skipping"
|
|
117
|
+
continue
|
|
118
|
+
fi
|
|
119
|
+
while read -r ip; do
|
|
120
|
+
[[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { echo "ERROR: Invalid DNS IP for $domain: $ip"; exit 1; }
|
|
121
|
+
ipset add -exist allowed-domains "$ip"
|
|
122
|
+
done < <(echo "$ips")
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
HOST_IP=$(ip route | awk '/default/ {print $3; exit}')
|
|
126
|
+
[[ -n "$HOST_IP" ]] || { echo "ERROR: Failed to detect host IP"; exit 1; }
|
|
127
|
+
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
|
|
128
|
+
|
|
129
|
+
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
|
|
130
|
+
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
|
131
|
+
|
|
132
|
+
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
|
|
133
|
+
echo "ERROR: Firewall verification failed - reached https://example.com"
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1 || {
|
|
137
|
+
echo "ERROR: Firewall verification failed - cannot reach api.github.com"
|
|
138
|
+
exit 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
touch "$READY_FLAG"
|
|
142
|
+
echo "Firewall configuration complete"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Baked into the image at /usr/local/bin, so the workspace can't be derived from
|
|
5
|
+
# this script's location. The runner execs it with the workspace as the working
|
|
6
|
+
# dir; $1 overrides that when given.
|
|
7
|
+
WS="${1:-${PWD}}"
|
|
8
|
+
|
|
9
|
+
# Egress lockdown gate: when the operator enabled the firewall, refuse to run
|
|
10
|
+
# anything derived from the untrusted workspace (uv sync executes the repo's
|
|
11
|
+
# build backend) until init-firewall.sh has established default-deny egress.
|
|
12
|
+
# The runner execs the firewall first; this fails closed if that ordering is
|
|
13
|
+
# ever lost.
|
|
14
|
+
if [[ "${TREEBOX_FIREWALL:-}" == "1" && ! -f /run/treebox-firewall-ready ]]; then
|
|
15
|
+
echo "ERROR: firewall enabled but not initialized; refusing to run workspace setup." >&2
|
|
16
|
+
echo " init-firewall.sh must complete before post-create.sh." >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
if [[ -f "$WS/pyproject.toml" ]] && command -v uv >/dev/null 2>&1; then
|
|
21
|
+
echo "Running uv sync..."
|
|
22
|
+
(cd "$WS" && uv sync)
|
|
23
|
+
elif [[ ! -d "$WS/.venv" ]] && { [[ -f "$WS/requirements.txt" ]] || [[ -f "$WS/setup.py" ]]; }; then
|
|
24
|
+
echo "Creating Python virtual environment..."
|
|
25
|
+
(cd "$WS" && uv venv .venv)
|
|
26
|
+
if [[ -f "$WS/requirements.txt" ]]; then
|
|
27
|
+
echo "Installing requirements.txt..."
|
|
28
|
+
(cd "$WS" && uv pip install -r requirements.txt)
|
|
29
|
+
fi
|
|
30
|
+
if [[ -f "$WS/setup.py" ]]; then
|
|
31
|
+
echo "Installing project in editable mode..."
|
|
32
|
+
(cd "$WS" && uv pip install -e .)
|
|
33
|
+
fi
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [[ -d "$HOME/.ssh" ]]; then
|
|
37
|
+
chmod 700 "$HOME/.ssh" 2>/dev/null || true
|
|
38
|
+
chmod 600 "$HOME/.ssh"/* 2>/dev/null || true
|
|
39
|
+
chmod 644 "$HOME/.ssh"/*.pub 2>/dev/null || true
|
|
40
|
+
chmod 644 "$HOME/.ssh/known_hosts" 2>/dev/null || true
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if command -v playwright-cli >/dev/null 2>&1; then
|
|
44
|
+
echo "Installing playwright skills..."
|
|
45
|
+
(cd "$WS" && playwright-cli install --skills)
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
echo "post-create.sh done."
|
treebox/assets/pre-push
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# treebox pre-push guard: placeholder branches are un-pushable by design.
|
|
3
|
+
#
|
|
4
|
+
# Installed per-worktree by `treebox create` (a private core.hooksPath — never
|
|
5
|
+
# the shared repo hooks). The treebox/ prefix marks an auto-generated name; a
|
|
6
|
+
# PR must never carry one. Renaming clears the guard — use a conventional
|
|
7
|
+
# branch name (feature/<name>, fix/<name>, chore/<name>, …):
|
|
8
|
+
# git branch -m <type>/<short-name>
|
|
9
|
+
# `git push --no-verify` remains the deliberate human escape hatch.
|
|
10
|
+
status=0
|
|
11
|
+
while read -r local_ref _local_oid _remote_ref _remote_oid; do
|
|
12
|
+
case "$local_ref" in
|
|
13
|
+
refs/heads/treebox/*)
|
|
14
|
+
branch="${local_ref#refs/heads/}"
|
|
15
|
+
{
|
|
16
|
+
printf '%s\n' "✗ treebox: refusing to push placeholder branch '$branch'."
|
|
17
|
+
printf '%s\n' " ↳ name this work first, then push again: git branch -m <type>/<short-name>"
|
|
18
|
+
printf '%s\n' " ↳ we use conventional-commits style branch names — pick the type that fits"
|
|
19
|
+
printf '%s\n' " the change: feature/user-auth, fix/login-race, chore/bump-deps,"
|
|
20
|
+
printf '%s\n' " docs/api-guide, refactor/db-layer, test/flaky-suite, …"
|
|
21
|
+
printf '%s\n' " ↳ the treebox/ prefix marks auto-generated names; PRs should never carry them."
|
|
22
|
+
} >&2
|
|
23
|
+
status=1
|
|
24
|
+
;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
exit $status
|
treebox/assets.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Resolve the sandbox container template directory.
|
|
2
|
+
|
|
3
|
+
The sandbox template is operator-owned and never read from the target repo: a
|
|
4
|
+
repo you don't trust must not define the container it runs in (mounts, runArgs,
|
|
5
|
+
and env can all reach the host). The agent runs *inside* the box; the config
|
|
6
|
+
that defines the box lives where a boxed agent cannot see or edit it.
|
|
7
|
+
|
|
8
|
+
Templates are selectable by name so devs can keep several sandbox shapes
|
|
9
|
+
(``python``, ``node``, a locked-down vs. a permissive one) side by side and pick
|
|
10
|
+
one per run with ``--template``. Resolution order for ``name``:
|
|
11
|
+
|
|
12
|
+
1. ``$TREEBOX_TEMPLATE_DIR`` (explicit dir; wins for any name)
|
|
13
|
+
2. ``$XDG_CONFIG_HOME/treebox/templates/<name>``
|
|
14
|
+
3. the bundled package data (``assets/container``) — only for the default.
|
|
15
|
+
|
|
16
|
+
A named template that resolves to none of these is an error, not a silent
|
|
17
|
+
fallback to the default: asking for ``--template hardened`` and quietly getting
|
|
18
|
+
the stock box would defeat the point.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from importlib import resources
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
DEFAULT_TEMPLATE = "default"
|
|
28
|
+
|
|
29
|
+
# The container definition the docker runner renders and runs. Its schema is
|
|
30
|
+
# treebox-owned: build.{dockerfile,args}, user, mounts, env, runArgs, postCreate.
|
|
31
|
+
CONFIG_FILE = "container.json"
|
|
32
|
+
|
|
33
|
+
TEMPLATE_FILES = (
|
|
34
|
+
CONFIG_FILE,
|
|
35
|
+
"Dockerfile",
|
|
36
|
+
"post-create.sh",
|
|
37
|
+
"init-firewall.sh",
|
|
38
|
+
"allowed-domains.sh",
|
|
39
|
+
)
|
|
40
|
+
FIREWALL_FILE = "firewall.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _user_templates_root() -> Path:
|
|
44
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
45
|
+
return Path(base).expanduser() / "treebox" / "templates"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def template_dir(name: str = DEFAULT_TEMPLATE) -> Path:
|
|
49
|
+
"""Resolve the operator-owned template directory for ``name``.
|
|
50
|
+
|
|
51
|
+
Never reads from the target repo — see the module docstring for why.
|
|
52
|
+
"""
|
|
53
|
+
explicit = os.environ.get("TREEBOX_TEMPLATE_DIR")
|
|
54
|
+
if explicit:
|
|
55
|
+
return Path(explicit).expanduser()
|
|
56
|
+
|
|
57
|
+
user = _user_templates_root() / name
|
|
58
|
+
if user.is_dir():
|
|
59
|
+
return user
|
|
60
|
+
|
|
61
|
+
if name == DEFAULT_TEMPLATE:
|
|
62
|
+
with resources.as_file(resources.files("treebox") / "assets" / "container") as p:
|
|
63
|
+
return Path(p)
|
|
64
|
+
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"No template named '{name}'. Create one at {user} "
|
|
67
|
+
f"(or point $TREEBOX_TEMPLATE_DIR at a template dir). "
|
|
68
|
+
f"'{DEFAULT_TEMPLATE}' is the only built-in template."
|
|
69
|
+
)
|