vibebox 0.0.2 → 0.0.3
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.
- package/Dockerfile +48 -8
- package/README.md +12 -27
- package/container-scripts/prompt +49 -0
- package/container-scripts/startup.sh +24 -21
- package/container-scripts/watcher.sh +38 -29
- package/dist/agents/auth.js +10 -1
- package/dist/agents/claude/index.js +4 -5
- package/dist/agents/index.js +7 -7
- package/dist/index.js +204 -94
- package/dist/update.js +5 -3
- package/package.json +3 -3
package/Dockerfile
CHANGED
|
@@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
17
17
|
fzf \
|
|
18
18
|
gh \
|
|
19
19
|
git \
|
|
20
|
+
gosu \
|
|
20
21
|
openssh-client \
|
|
21
22
|
gnupg2 \
|
|
22
23
|
iproute2 \
|
|
@@ -38,11 +39,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
38
39
|
# Create parent directory for non-standard home paths (e.g., /Users/joe on macOS)
|
|
39
40
|
RUN mkdir -p $(dirname $LOCAL_HOME) 2>/dev/null || true
|
|
40
41
|
|
|
41
|
-
# Create group and user
|
|
42
|
+
# Create group and user (handle case where UID already exists, e.g. in CI)
|
|
42
43
|
RUN groupadd -g $LOCAL_GID $LOCAL_USER 2>/dev/null || true && \
|
|
43
44
|
GROUP_NAME=$(getent group $LOCAL_GID | cut -d: -f1) && \
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
if id -u $LOCAL_UID >/dev/null 2>&1; then \
|
|
46
|
+
EXISTING_USER=$(getent passwd $LOCAL_UID | cut -d: -f1) && \
|
|
47
|
+
usermod -d $LOCAL_HOME -s /bin/zsh -l $LOCAL_USER -g $GROUP_NAME $EXISTING_USER 2>/dev/null || true && \
|
|
48
|
+
mkdir -p $LOCAL_HOME && chown $LOCAL_UID:$LOCAL_GID $LOCAL_HOME; \
|
|
49
|
+
else \
|
|
50
|
+
useradd -m -s /bin/zsh -u $LOCAL_UID -g $GROUP_NAME -d $LOCAL_HOME $LOCAL_USER; \
|
|
51
|
+
fi && \
|
|
52
|
+
touch /etc/sudoers.d/vibebox-user && chmod 440 /etc/sudoers.d/vibebox-user
|
|
46
53
|
|
|
47
54
|
USER $LOCAL_USER
|
|
48
55
|
WORKDIR $LOCAL_HOME
|
|
@@ -60,16 +67,20 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | b
|
|
|
60
67
|
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
|
61
68
|
. "$NVM_DIR/nvm.sh" && npm install -g sfw
|
|
62
69
|
|
|
70
|
+
# Add SSH known hosts for common git providers
|
|
71
|
+
RUN mkdir -p $LOCAL_HOME/.ssh && chmod 700 $LOCAL_HOME/.ssh && \
|
|
72
|
+
ssh-keyscan -t ed25519,rsa github.com gitlab.com bitbucket.org >> $LOCAL_HOME/.ssh/known_hosts 2>/dev/null && \
|
|
73
|
+
chmod 600 $LOCAL_HOME/.ssh/known_hosts
|
|
74
|
+
|
|
63
75
|
# Configure zsh
|
|
64
76
|
RUN echo 'export NVM_DIR="$HOME/.nvm"' >> $LOCAL_HOME/.zshrc && \
|
|
65
77
|
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $LOCAL_HOME/.zshrc && \
|
|
66
|
-
echo '
|
|
67
|
-
echo 'setopt PROMPT_SUBST' >> $LOCAL_HOME/.zshrc && \
|
|
78
|
+
echo 'source $HOME/.local/bin/prompt' >> $LOCAL_HOME/.zshrc && \
|
|
68
79
|
echo 'alias npm="sfw npm"' >> $LOCAL_HOME/.zshrc && \
|
|
69
80
|
echo 'alias npx="sfw npx"' >> $LOCAL_HOME/.zshrc && \
|
|
70
81
|
echo '$HOME/.local/bin/port-monitor.sh &!' >> $LOCAL_HOME/.zshrc
|
|
71
82
|
|
|
72
|
-
# Copy container scripts
|
|
83
|
+
# Copy container scripts (as user)
|
|
73
84
|
COPY --chown=$LOCAL_USER:$LOCAL_GID container-scripts/ $LOCAL_HOME/.local/bin/
|
|
74
85
|
RUN chmod +x $LOCAL_HOME/.local/bin/*.sh
|
|
75
86
|
|
|
@@ -81,6 +92,35 @@ ENV SHELL=/bin/zsh \
|
|
|
81
92
|
VISUAL=nano \
|
|
82
93
|
DEVCONTAINER=true \
|
|
83
94
|
PATH="$LOCAL_HOME/.local/bin:$LOCAL_HOME/.nvm/versions/node/v$NODE_VERSION/bin:$PATH" \
|
|
84
|
-
NODE_PATH="$LOCAL_HOME/.nvm/versions/node/v$NODE_VERSION/lib/node_modules"
|
|
95
|
+
NODE_PATH="$LOCAL_HOME/.nvm/versions/node/v$NODE_VERSION/lib/node_modules" \
|
|
96
|
+
LOCAL_USER=$LOCAL_USER \
|
|
97
|
+
LOCAL_HOME=$LOCAL_HOME
|
|
98
|
+
|
|
99
|
+
# Create entrypoint (runs as root, sets up sudo if enabled, then drops to user)
|
|
100
|
+
USER root
|
|
101
|
+
RUN cat <<'ENTRYPOINT_EOF' > /entrypoint.sh
|
|
102
|
+
#!/bin/bash
|
|
103
|
+
set -e
|
|
104
|
+
LOCAL_USER="${LOCAL_USER:-coder}"
|
|
105
|
+
LOCAL_HOME="${LOCAL_HOME:-/home/$LOCAL_USER}"
|
|
106
|
+
|
|
107
|
+
# Enable sudo if VIBEBOX_SUDO=1
|
|
108
|
+
if [[ "$VIBEBOX_SUDO" == "1" ]]; then
|
|
109
|
+
echo "$LOCAL_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vibebox-user
|
|
110
|
+
chmod 440 /etc/sudoers.d/vibebox-user
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# Fix SSH socket permissions (Docker on macOS mounts as root:root)
|
|
114
|
+
# Use 600 (owner-only) instead of 666 (world-writable) for security
|
|
115
|
+
if [[ -S /ssh-agent ]]; then
|
|
116
|
+
chown "$LOCAL_USER" /ssh-agent 2>/dev/null || true
|
|
117
|
+
chmod 600 /ssh-agent 2>/dev/null || true
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Drop to user and run startup.sh (gosu preserves env vars, unlike su -)
|
|
121
|
+
exec gosu "$LOCAL_USER" "$LOCAL_HOME/.local/bin/startup.sh" "$@"
|
|
122
|
+
ENTRYPOINT_EOF
|
|
123
|
+
RUN chmod +x /entrypoint.sh
|
|
85
124
|
|
|
86
|
-
|
|
125
|
+
ENTRYPOINT ["/entrypoint.sh"]
|
|
126
|
+
CMD ["tail", "-f", "/dev/null"]
|
package/README.md
CHANGED
|
@@ -44,9 +44,9 @@ vibebox rebuild # Rebuild image with current host config
|
|
|
44
44
|
|
|
45
45
|
# Customization (experimental)
|
|
46
46
|
vibebox customize setup global # Edit ~/.vibebox/setup.sh (runs in all sandboxes)
|
|
47
|
-
vibebox customize setup local # Edit .
|
|
47
|
+
vibebox customize setup local # Edit vibebox.setup.sh (runs in this project)
|
|
48
48
|
vibebox customize image global # Edit ~/.vibebox/Dockerfile (custom base image)
|
|
49
|
-
vibebox customize image local # Edit .
|
|
49
|
+
vibebox customize image local # Edit vibebox.Dockerfile (custom project-specific image)
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
## Temporary Workspaces
|
|
@@ -100,7 +100,7 @@ Choose update strategy:
|
|
|
100
100
|
|
|
101
101
|
Git config is automatically synced from host (user.name, user.email, aliases, common settings).
|
|
102
102
|
|
|
103
|
-
**SSH agent forwarding:**
|
|
103
|
+
**SSH agent forwarding:** Your SSH agent is forwarded to the container. Keys never leave the host. Works with Docker Desktop (macOS) and native Docker (Linux).
|
|
104
104
|
|
|
105
105
|
**GitHub CLI:** If `~/.config/gh/hosts.yml` exists, it's mounted into the container and `gh auth setup-git` runs automatically.
|
|
106
106
|
|
|
@@ -114,11 +114,15 @@ Git config is automatically synced from host (user.name, user.email, aliases, co
|
|
|
114
114
|
|
|
115
115
|
**Setup scripts** run once when a container is first created:
|
|
116
116
|
- `~/.vibebox/setup.sh` - Global, runs in all sandboxes
|
|
117
|
-
-
|
|
117
|
+
- `vibebox.setup.sh` - Local, runs in this project sandbox only
|
|
118
118
|
|
|
119
119
|
**Custom Dockerfiles** extend the base image:
|
|
120
120
|
- `~/.vibebox/Dockerfile` - Global customizations (`FROM vibebox`)
|
|
121
|
-
-
|
|
121
|
+
- `vibebox.Dockerfile` - Project-specific (`FROM vibebox:user` or `FROM vibebox`)
|
|
122
|
+
|
|
123
|
+
**Config** is stored in `vibebox.config.json` at project root (ports, sudo settings).
|
|
124
|
+
|
|
125
|
+
All `vibebox.*` files at project root are meant to be git-tracked. The `.vibebox/` folder contains only runtime artifacts and can be gitignored.
|
|
122
126
|
|
|
123
127
|
Image hierarchy: `vibebox` → `vibebox:user` (global) → `vibebox:<hash>` (local)
|
|
124
128
|
|
|
@@ -126,30 +130,11 @@ Run `vibebox rebuild` after editing Dockerfiles.
|
|
|
126
130
|
|
|
127
131
|
## Why vibebox?
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
Docker Desktop includes an experimental `docker sandbox` command for running agents (Claude Code, Gemini) in containers. Here's why vibebox takes a different approach:
|
|
132
|
-
|
|
133
|
-
- **Transparency over black box**: `docker sandbox` is opaque. You can't see what it mounts, how it configures the environment, debug issues, or customize the image. vibebox is simple TypeScript you can read and modify.
|
|
134
|
-
- **No `--dangerously-skip-permissions`**: `docker sandbox` runs Claude with all permissions bypassed. vibebox respects your existing Claude settings and permission model, including allowing you to launch with `--dangerously-skip-permissions` if that's your thing.
|
|
135
|
-
- **Shared credentials**: `docker sandbox` doesn't share auth with your host, so you log in fresh every time you create a sandbox. vibebox syncs credentials bidirectionally with your system keychain.
|
|
136
|
-
- **Works with any Docker runtime**: `docker sandbox` requires Docker Desktop. vibebox works with Docker Engine, Colima, or any Docker-compliant runtime.
|
|
137
|
-
- **Interactive shell access**: `vibebox enter` drops you into a shell inside the container. Useful for debugging, running commands, or working alongside the agent.
|
|
138
|
-
- **Port management**: vibebox automatically exposes common dev ports with dynamic host allocation, shows you the mappings when services start, and lets you add custom ports.
|
|
139
|
-
- **Persistent containers**: Containers persist between sessions. Your installed packages, build artifacts, and environment stay intact until you explicitly remove them.
|
|
140
|
-
|
|
141
|
-
### vs raw docker
|
|
142
|
-
|
|
143
|
-
- **Zero boilerplate**: No writing Dockerfiles, figuring out mount paths, or managing `docker run` flags. Just `vibebox agent run claude`.
|
|
144
|
-
- **Automatic credential sync**: Credentials are extracted from your system keychain and mounted into the container. No manual token copying.
|
|
145
|
-
- **User matching**: Container user matches your host UID/GID/home path, so file permissions just work.
|
|
146
|
-
- **Built-in port detection**: When a service starts listening, vibebox shows you the mapped URL. No guessing which host port maps where.
|
|
133
|
+
**vs `docker sandbox`**: Docker Desktop's sandbox uses microVM isolation—stronger security, but a blackbox. No credential sync (re-auth every session), no customization, runs in full yolo mode. On Linux it falls back to container isolation anyway. vibebox trades some isolation for **continuity**: credentials sync, config persists, you can customize and debug.
|
|
147
134
|
|
|
148
|
-
|
|
135
|
+
**vs raw docker**: No Dockerfiles, no mount flags, no manual credential copying. Just `vibebox agent run claude`. Container user matches your UID/GID so file permissions work. Port mappings shown automatically when services start.
|
|
149
136
|
|
|
150
|
-
|
|
151
|
-
- **Simpler model**: No `devcontainer.json`, no features, no lifecycle hooks. One command to start.
|
|
152
|
-
- **Agent-first**: Built specifically for CLI agents with credential mounting, not general-purpose dev environments.
|
|
137
|
+
**vs devcontainers**: No VS Code dependency, no `devcontainer.json`. Built for CLI agents, not general dev environments.
|
|
153
138
|
|
|
154
139
|
## License
|
|
155
140
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
prompt_git() {
|
|
2
|
+
# check if the current directory is in a git repository
|
|
3
|
+
if [ $(git rev-parse --is-inside-work-tree &>/dev/null; print $?) = 0 ]; then
|
|
4
|
+
# check if the current directory is in .git before running git checks
|
|
5
|
+
if [ "$(git rev-parse --is-inside-git-dir 2> /dev/null)" = "false" ]; then
|
|
6
|
+
|
|
7
|
+
# ensure index is up to date
|
|
8
|
+
git update-index --really-refresh -q &>/dev/null
|
|
9
|
+
|
|
10
|
+
# check for uncommitted changes in the index
|
|
11
|
+
if ! $(git diff --quiet --ignore-submodules --cached); then
|
|
12
|
+
s="$s+";
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# check for unstaged changes
|
|
16
|
+
if ! $(git diff-files --quiet --ignore-submodules --); then
|
|
17
|
+
s="$s!";
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# check for untracked files
|
|
21
|
+
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
|
22
|
+
s="$s?";
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# check for stashed files
|
|
26
|
+
# if $(git rev-parse --verify refs/stash &>/dev/null); then
|
|
27
|
+
# s="$s$";
|
|
28
|
+
# fi
|
|
29
|
+
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# get the short symbolic ref
|
|
33
|
+
# if HEAD isn't a symbolic ref, get the short SHA
|
|
34
|
+
# otherwise, just give up
|
|
35
|
+
branchName="$(git symbolic-ref --quiet --short HEAD 2> /dev/null || \
|
|
36
|
+
git rev-parse --short HEAD 2> /dev/null || \
|
|
37
|
+
printf "(unknown)")"
|
|
38
|
+
|
|
39
|
+
[ -n "$s" ] && s="[$s] "
|
|
40
|
+
|
|
41
|
+
printf "%s %s %s" "$1 " "$branchName" "$s"
|
|
42
|
+
else
|
|
43
|
+
return
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Set up the prompt (with git branch name)
|
|
48
|
+
setopt prompt_subst
|
|
49
|
+
PROMPT='🇻 %~ $(prompt_git '⎇')%(!.#.\$) '
|
|
@@ -3,7 +3,8 @@ set -e
|
|
|
3
3
|
|
|
4
4
|
HOME_DIR="$HOME"
|
|
5
5
|
WORKSPACE="${VIBEBOX_PROJECT_ROOT:-$(pwd)}"
|
|
6
|
-
|
|
6
|
+
# Store setup marker in workspace .vibebox/ (writable), not ~/.vibebox (read-only)
|
|
7
|
+
SETUP_MARKER="$WORKSPACE/.vibebox/.setup-done"
|
|
7
8
|
|
|
8
9
|
# ============ Git Config Sync ============
|
|
9
10
|
# Apply safe git settings from host (passed as VIBEBOX_GIT_* env vars)
|
|
@@ -18,13 +19,17 @@ setup_git_config() {
|
|
|
18
19
|
[[ -n "$VIBEBOX_GIT_HELP_AUTOCORRECT" ]] && git config --global help.autocorrect "$VIBEBOX_GIT_HELP_AUTOCORRECT"
|
|
19
20
|
|
|
20
21
|
# Apply git aliases (passed as JSON in VIBEBOX_GIT_ALIASES)
|
|
22
|
+
# Node calls git config directly to avoid shell injection via alias values
|
|
21
23
|
if [[ -n "$VIBEBOX_GIT_ALIASES" ]]; then
|
|
22
24
|
echo "$VIBEBOX_GIT_ALIASES" | node -e "
|
|
25
|
+
const { execFileSync } = require('child_process');
|
|
23
26
|
const aliases = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
24
|
-
for (const [name, cmd] of Object.entries(aliases))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
for (const [name, cmd] of Object.entries(aliases)) {
|
|
28
|
+
try {
|
|
29
|
+
execFileSync('git', ['config', '--global', 'alias.' + name, cmd], { stdio: 'pipe' });
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
" 2>/dev/null || true
|
|
28
33
|
fi
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -58,7 +63,7 @@ show_ssh_hint() {
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
# ============ User Setup Scripts ============
|
|
61
|
-
# Run once per container: global ~/.vibebox/setup.sh then local .
|
|
66
|
+
# Run once per container: global ~/.vibebox/setup.sh then local vibebox.setup.sh
|
|
62
67
|
|
|
63
68
|
run_setup_scripts() {
|
|
64
69
|
if [[ -f "$SETUP_MARKER" ]]; then
|
|
@@ -67,35 +72,33 @@ run_setup_scripts() {
|
|
|
67
72
|
|
|
68
73
|
# Global setup (runs in all sandboxes)
|
|
69
74
|
local global_setup="$HOME_DIR/.vibebox/setup.sh"
|
|
70
|
-
if [[ -
|
|
75
|
+
if [[ -f "$global_setup" ]]; then
|
|
71
76
|
echo "[vibebox] Running global setup..."
|
|
72
|
-
"$global_setup"
|
|
77
|
+
if [[ -x "$global_setup" ]]; then
|
|
78
|
+
"$global_setup" || echo "[vibebox] Global setup failed (continuing)"
|
|
79
|
+
else
|
|
80
|
+
zsh "$global_setup" || echo "[vibebox] Global setup failed (continuing)"
|
|
81
|
+
fi
|
|
73
82
|
fi
|
|
74
83
|
|
|
75
84
|
# Local setup (project-specific)
|
|
76
|
-
local local_setup="$WORKSPACE
|
|
77
|
-
if [[ -
|
|
85
|
+
local local_setup="$WORKSPACE/vibebox.setup.sh"
|
|
86
|
+
if [[ -f "$local_setup" ]]; then
|
|
78
87
|
echo "[vibebox] Running local setup..."
|
|
79
|
-
"$local_setup"
|
|
88
|
+
if [[ -x "$local_setup" ]]; then
|
|
89
|
+
"$local_setup" || echo "[vibebox] Local setup failed (continuing)"
|
|
90
|
+
else
|
|
91
|
+
zsh "$local_setup" || echo "[vibebox] Local setup failed (continuing)"
|
|
92
|
+
fi
|
|
80
93
|
fi
|
|
81
94
|
|
|
82
95
|
mkdir -p "$(dirname "$SETUP_MARKER")"
|
|
83
96
|
touch "$SETUP_MARKER"
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
# ============ SSH Agent Socket Fix ============
|
|
87
|
-
# Docker on macOS mounts sockets as root:root, fix permissions if needed
|
|
88
|
-
|
|
89
|
-
fix_ssh_socket() {
|
|
90
|
-
if [[ -S /ssh-agent ]] && ! [[ -r /ssh-agent ]]; then
|
|
91
|
-
sudo chmod 666 /ssh-agent 2>/dev/null || true
|
|
92
|
-
fi
|
|
93
|
-
}
|
|
94
|
-
|
|
95
99
|
# ============ Main ============
|
|
96
100
|
|
|
97
101
|
# Run setup on container start
|
|
98
|
-
fix_ssh_socket
|
|
99
102
|
setup_git_config
|
|
100
103
|
setup_gh_cli
|
|
101
104
|
run_setup_scripts
|
|
@@ -1,45 +1,54 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# Idle watcher - exits after TIMEOUT seconds of no
|
|
2
|
+
# Idle watcher - exits after TIMEOUT seconds of no active sessions
|
|
3
|
+
# Sessions are tracked via heartbeat files (lock files touched every 5s by host)
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
IDLE_FILE="$LOCKS_DIR/idle"
|
|
5
|
+
# Use workspace's .vibebox/locks (writable), not ~/.vibebox (read-only)
|
|
6
|
+
LOCKS_DIR="${VIBEBOX_PROJECT_ROOT:-.}/.vibebox/locks"
|
|
7
7
|
TIMEOUT=${1:-300}
|
|
8
|
+
STALE_THRESHOLD=15 # Lock considered stale if not updated in 15 seconds
|
|
8
9
|
|
|
9
|
-
# Ensure locks directory exists
|
|
10
10
|
mkdir -p "$LOCKS_DIR"
|
|
11
11
|
|
|
12
12
|
echo "Starting watcher with ${TIMEOUT}s idle timeout"
|
|
13
13
|
|
|
14
|
+
idle_start=""
|
|
15
|
+
|
|
14
16
|
while true; do
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
now=$(date +%s)
|
|
18
|
+
active_sessions=0
|
|
19
|
+
|
|
20
|
+
# Check each lock file's modification time
|
|
21
|
+
for lock in "$LOCKS_DIR"/session-*.lock; do
|
|
22
|
+
[ -f "$lock" ] || continue
|
|
23
|
+
|
|
24
|
+
# Get modification time (Linux stat syntax, container is Ubuntu)
|
|
25
|
+
mtime=$(stat -c %Y "$lock" 2>/dev/null)
|
|
26
|
+
[ -z "$mtime" ] && continue
|
|
27
|
+
|
|
28
|
+
age=$((now - mtime))
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if [ "$lock_count" -eq 0 ]; then
|
|
26
|
-
# No session locks - check/start idle timer
|
|
27
|
-
if [ -f "$IDLE_FILE" ]; then
|
|
28
|
-
idle_start=$(cat "$IDLE_FILE")
|
|
29
|
-
now=$(date +%s)
|
|
30
|
-
elapsed=$((now - idle_start))
|
|
31
|
-
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
|
32
|
-
echo "Idle timeout of ${TIMEOUT}s reached. Exiting."
|
|
33
|
-
rm -f "$IDLE_FILE"
|
|
34
|
-
exit 0
|
|
35
|
-
fi
|
|
30
|
+
if [ "$age" -lt "$STALE_THRESHOLD" ]; then
|
|
31
|
+
active_sessions=$((active_sessions + 1))
|
|
36
32
|
else
|
|
37
|
-
#
|
|
38
|
-
|
|
33
|
+
# Stale lock - session died without cleanup
|
|
34
|
+
rm -f "$lock"
|
|
35
|
+
fi
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
if [ "$active_sessions" -eq 0 ]; then
|
|
39
|
+
# No active sessions - manage idle timer
|
|
40
|
+
if [ -z "$idle_start" ]; then
|
|
41
|
+
idle_start=$now
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
elapsed=$((now - idle_start))
|
|
45
|
+
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
|
46
|
+
echo "Idle timeout of ${TIMEOUT}s reached. Exiting."
|
|
47
|
+
exit 0
|
|
39
48
|
fi
|
|
40
49
|
else
|
|
41
|
-
#
|
|
42
|
-
|
|
50
|
+
# Active sessions - reset idle timer
|
|
51
|
+
idle_start=""
|
|
43
52
|
fi
|
|
44
53
|
|
|
45
54
|
sleep 1
|
package/dist/agents/auth.js
CHANGED
|
@@ -60,7 +60,16 @@ function getCredentialStore() {
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
set: (service, account, value) => {
|
|
63
|
-
|
|
63
|
+
// Use execFileSync with input pipe to avoid shell injection via value
|
|
64
|
+
(0, node_child_process_1.execFileSync)("secret-tool", [
|
|
65
|
+
"store",
|
|
66
|
+
`--label=${service}`,
|
|
67
|
+
"service", service,
|
|
68
|
+
"account", account,
|
|
69
|
+
], {
|
|
70
|
+
input: value,
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
});
|
|
64
73
|
},
|
|
65
74
|
};
|
|
66
75
|
}
|
|
@@ -84,7 +84,6 @@ exports.claude = {
|
|
|
84
84
|
// Container-only mode: minimal mounts, container manages its own ~/.claude
|
|
85
85
|
if (containerOnly) {
|
|
86
86
|
return [
|
|
87
|
-
// Mount config copy (not the original, to prevent corruption)
|
|
88
87
|
"-v", `${configCopy}:${config}`,
|
|
89
88
|
];
|
|
90
89
|
}
|
|
@@ -107,7 +106,7 @@ exports.claude = {
|
|
|
107
106
|
},
|
|
108
107
|
install: (containerName) => {
|
|
109
108
|
console.log("Installing Claude Code...");
|
|
110
|
-
(0, node_child_process_1.spawnSync)("docker", ["exec", containerName, "bash", "-c", "curl -fsSL https://claude.ai/install.sh | sh"], { stdio: "inherit" });
|
|
109
|
+
(0, node_child_process_1.spawnSync)("docker", ["exec", "-u", (0, node_os_1.userInfo)().username, containerName, "bash", "-c", "curl -fsSL https://claude.ai/install.sh | sh"], { stdio: "inherit" });
|
|
111
110
|
},
|
|
112
111
|
setup: ({ workspace, containerOnly }) => {
|
|
113
112
|
const home = (0, node_os_1.homedir)();
|
|
@@ -119,8 +118,8 @@ exports.claude = {
|
|
|
119
118
|
const emptyFile = (0, node_path_1.join)(workspace, ".vibebox", "empty-file");
|
|
120
119
|
if (!(0, node_fs_1.existsSync)(emptyFile))
|
|
121
120
|
(0, node_fs_1.writeFileSync)(emptyFile, "");
|
|
122
|
-
// Create minimal config to skip onboarding
|
|
123
|
-
const
|
|
121
|
+
// Create minimal config to skip onboarding (mounted to ~/.claude.json)
|
|
122
|
+
const configFile = (0, node_path_1.join)(workspace, ".vibebox", "claude.json");
|
|
124
123
|
const minimalConfig = {
|
|
125
124
|
hasCompletedOnboarding: true,
|
|
126
125
|
projects: {
|
|
@@ -131,7 +130,7 @@ exports.claude = {
|
|
|
131
130
|
},
|
|
132
131
|
},
|
|
133
132
|
};
|
|
134
|
-
(0, node_fs_1.writeFileSync)(
|
|
133
|
+
(0, node_fs_1.writeFileSync)(configFile, JSON.stringify(minimalConfig, null, 2));
|
|
135
134
|
// Container-only mode: skip credential sync, agent will prompt for login
|
|
136
135
|
if (containerOnly)
|
|
137
136
|
return;
|
package/dist/agents/index.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.agents = void 0;
|
|
|
4
4
|
exports.detectInstalledAgents = detectInstalledAgents;
|
|
5
5
|
exports.isAgentInstalled = isAgentInstalled;
|
|
6
6
|
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_os_1 = require("node:os");
|
|
7
8
|
const claude_1 = require("./claude");
|
|
8
9
|
exports.agents = {
|
|
9
10
|
claude: claude_1.claude,
|
|
@@ -14,11 +15,10 @@ function detectInstalledAgents() {
|
|
|
14
15
|
.map(([name]) => name);
|
|
15
16
|
}
|
|
16
17
|
function isAgentInstalled(containerName, agent) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
18
|
+
// Use spawnSync to fully suppress stderr (execFileSync can still leak on error)
|
|
19
|
+
const result = (0, node_child_process_1.spawnSync)("docker", ["exec", "-u", (0, node_os_1.userInfo)().username, containerName, "which", agent.command], {
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
});
|
|
23
|
+
return result.status === 0;
|
|
24
24
|
}
|
package/dist/index.js
CHANGED
|
@@ -71,7 +71,7 @@ agent
|
|
|
71
71
|
containerOnly = true;
|
|
72
72
|
}
|
|
73
73
|
agentDef.setup?.({ workspace, containerOnly });
|
|
74
|
-
const containerName = ensureSandbox({ workspace, agents: [agentDef], containerOnly });
|
|
74
|
+
const containerName = await ensureSandbox({ workspace, agents: [agentDef], containerOnly });
|
|
75
75
|
agentDef.install(containerName);
|
|
76
76
|
console.log(`✓ ${agentDef.name} installed`);
|
|
77
77
|
});
|
|
@@ -98,6 +98,7 @@ async function runAgent(name, args, opts) {
|
|
|
98
98
|
// Check if we need container-only mode
|
|
99
99
|
const existing = getSandboxName(workspace);
|
|
100
100
|
const existingRunning = existing && isContainerRunning(existing);
|
|
101
|
+
// Only check if agent is installed when container is running (docker exec requires running container)
|
|
101
102
|
const needsInstall = !existingRunning || !(0, agents_1.isAgentInstalled)(existing, agent);
|
|
102
103
|
const hostInstalled = agent.isInstalledOnHost();
|
|
103
104
|
let containerOnly = false;
|
|
@@ -108,7 +109,7 @@ async function runAgent(name, args, opts) {
|
|
|
108
109
|
}
|
|
109
110
|
// Run agent setup (auth, config files)
|
|
110
111
|
agent.setup?.({ workspace, containerOnly });
|
|
111
|
-
const containerName = ensureSandbox({ workspace, agents: [agent], containerOnly });
|
|
112
|
+
const containerName = await ensureSandbox({ workspace, agents: [agent], containerOnly });
|
|
112
113
|
if (agent.versionCommand && !containerOnly) {
|
|
113
114
|
await (0, update_1.checkVersions)(containerName, agent);
|
|
114
115
|
}
|
|
@@ -126,13 +127,10 @@ async function runAgent(name, args, opts) {
|
|
|
126
127
|
agent.install(containerName);
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
|
-
const
|
|
130
|
-
name: containerName,
|
|
131
|
-
fn: () => (0, node_child_process_1.spawnSync)("docker", ["exec", "-it", containerName, agent.command, ...args], { stdio: "inherit" }),
|
|
132
|
-
});
|
|
130
|
+
const code = await withSessionLock({ name: containerName, workspace, cmd: [agent.command, ...args], interactive: true });
|
|
133
131
|
if (opts.temp)
|
|
134
132
|
await promptKeep(workspace);
|
|
135
|
-
process.exit(
|
|
133
|
+
process.exit(code);
|
|
136
134
|
}
|
|
137
135
|
async function promptContainerOnly(agentName) {
|
|
138
136
|
console.log(`\n${agentName} is not installed on your host machine.`);
|
|
@@ -161,12 +159,9 @@ commander_1.program
|
|
|
161
159
|
await (0, update_1.checkForUpdates)();
|
|
162
160
|
const workspace = process.cwd();
|
|
163
161
|
const installedAgents = setupInstalledAgents({ workspace });
|
|
164
|
-
const name = ensureSandbox({ workspace, agents: installedAgents });
|
|
162
|
+
const name = await ensureSandbox({ workspace, agents: installedAgents });
|
|
165
163
|
await (0, update_1.checkVersions)(name);
|
|
166
|
-
withSessionLock({
|
|
167
|
-
name,
|
|
168
|
-
fn: () => (0, node_child_process_1.spawnSync)("docker", ["exec", "-it", name, "/bin/zsh"], { stdio: "inherit" }),
|
|
169
|
-
});
|
|
164
|
+
await withSessionLock({ name, workspace, cmd: ["/bin/zsh"], interactive: true });
|
|
170
165
|
});
|
|
171
166
|
commander_1.program
|
|
172
167
|
.command("exec <cmd...>")
|
|
@@ -177,12 +172,9 @@ commander_1.program
|
|
|
177
172
|
await ensureDocker();
|
|
178
173
|
const workspace = process.cwd();
|
|
179
174
|
const installedAgents = setupInstalledAgents({ workspace });
|
|
180
|
-
const name = ensureSandbox({ workspace, agents: installedAgents });
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
fn: () => (0, node_child_process_1.spawnSync)("docker", ["exec", name, ...cmd], { stdio: "inherit" }),
|
|
184
|
-
});
|
|
185
|
-
process.exit(r.status ?? 0);
|
|
175
|
+
const name = await ensureSandbox({ workspace, agents: installedAgents });
|
|
176
|
+
const code = await withSessionLock({ name, workspace, cmd });
|
|
177
|
+
process.exit(code);
|
|
186
178
|
});
|
|
187
179
|
commander_1.program
|
|
188
180
|
.command("ls")
|
|
@@ -268,19 +260,16 @@ commander_1.program
|
|
|
268
260
|
commander_1.program
|
|
269
261
|
.command("rebuild")
|
|
270
262
|
.description("Force rebuild image with current host config")
|
|
271
|
-
.
|
|
263
|
+
.option("--no-cache", "Build without using Docker layer cache")
|
|
264
|
+
.action(async (opts) => {
|
|
272
265
|
await ensureDocker();
|
|
273
266
|
await (0, update_1.checkForUpdates)();
|
|
274
267
|
console.log("Rebuilding vibebox image...");
|
|
275
|
-
|
|
276
|
-
(0, node_child_process_1.execSync)("docker rmi vibebox", { stdio: "pipe" });
|
|
277
|
-
}
|
|
278
|
-
catch { }
|
|
279
|
-
buildImage();
|
|
268
|
+
buildImage({ noCache: !opts.cache });
|
|
280
269
|
console.log("Image rebuilt");
|
|
281
270
|
});
|
|
282
271
|
// ============ Ports Commands ============
|
|
283
|
-
const DEFAULT_PORTS = ["
|
|
272
|
+
const DEFAULT_PORTS = ["3000", "3001", "4173", "5000", "5173", "8080", "8081", "8545"];
|
|
284
273
|
const ports = commander_1.program.command("ports").description("Manage port mappings");
|
|
285
274
|
ports
|
|
286
275
|
.command("ls")
|
|
@@ -294,7 +283,7 @@ ports
|
|
|
294
283
|
try {
|
|
295
284
|
const mappings = getPortMappings(name);
|
|
296
285
|
// Get listening ports
|
|
297
|
-
const ssOut = (0, node_child_process_1.execFileSync)("docker", ["exec", name, "ss", "-tln"], { encoding: "utf8" });
|
|
286
|
+
const ssOut = (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", (0, node_os_1.userInfo)().username, name, "ss", "-tln"], { encoding: "utf8" });
|
|
298
287
|
const listening = new Set();
|
|
299
288
|
for (const line of ssOut.split("\n").slice(1)) {
|
|
300
289
|
const match = line.match(/:(\d+)\s*$/);
|
|
@@ -336,7 +325,7 @@ ports
|
|
|
336
325
|
saveConfig(ws, cfg);
|
|
337
326
|
console.log(`Ports: ${cfg.ports.join(", ")}`);
|
|
338
327
|
if (getSandboxName(ws))
|
|
339
|
-
console.log("
|
|
328
|
+
console.log("Port changes require container restart (Docker sets ports at creation time)");
|
|
340
329
|
});
|
|
341
330
|
ports
|
|
342
331
|
.command("rm <ports...>")
|
|
@@ -349,19 +338,21 @@ ports
|
|
|
349
338
|
saveConfig(ws, cfg);
|
|
350
339
|
console.log(`Ports: ${cfg.ports.join(", ")}`);
|
|
351
340
|
if (getSandboxName(ws))
|
|
352
|
-
console.log("
|
|
341
|
+
console.log("Port changes require container restart (Docker sets ports at creation time)");
|
|
353
342
|
});
|
|
354
343
|
// ============ Customize Command ============
|
|
355
344
|
const TEMPLATES = {
|
|
356
|
-
setupGlobal: `#!/bin/
|
|
345
|
+
setupGlobal: `#!/bin/zsh
|
|
357
346
|
# Runs once when a sandbox is created (all sandboxes)
|
|
358
347
|
# Example: git clone https://github.com/YOU/dotfiles ~/.dotfiles && ~/.dotfiles/install.sh
|
|
359
|
-
#
|
|
348
|
+
# Note: sudo is disabled by default. For apt installs, use ~/.vibebox/Dockerfile instead,
|
|
349
|
+
# or enable sudo with: vibebox config sudo on
|
|
360
350
|
`,
|
|
361
|
-
setupLocal: `#!/bin/
|
|
351
|
+
setupLocal: `#!/bin/zsh
|
|
362
352
|
# Runs once when this project's sandbox is created
|
|
363
353
|
# Example: pip install -r requirements.txt
|
|
364
|
-
# Example: echo 'export DATABASE_URL=...' >> ~/.
|
|
354
|
+
# Example: echo 'export DATABASE_URL=...' >> ~/.zshrc
|
|
355
|
+
# Note: sudo is disabled by default. Enable with: vibebox config sudo on
|
|
365
356
|
`,
|
|
366
357
|
imageGlobal: `# Baked into image, applies to ALL sandboxes. Run 'vibebox rebuild' after editing.
|
|
367
358
|
FROM vibebox
|
|
@@ -391,6 +382,9 @@ function openInEditor({ file, template, isExecutable }) {
|
|
|
391
382
|
(0, node_fs_1.writeFileSync)(file, template, isExecutable ? { mode: 0o755 } : undefined);
|
|
392
383
|
console.log(`Created ${file}`);
|
|
393
384
|
}
|
|
385
|
+
else if (isExecutable) {
|
|
386
|
+
(0, node_fs_1.chmodSync)(file, 0o755);
|
|
387
|
+
}
|
|
394
388
|
const editor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
395
389
|
const r = (0, node_child_process_1.spawnSync)(editor, [file], { stdio: "inherit" });
|
|
396
390
|
if (r.status !== 0)
|
|
@@ -401,30 +395,30 @@ customize
|
|
|
401
395
|
.command("image [scope]")
|
|
402
396
|
.description("Edit custom Dockerfile (global or local)")
|
|
403
397
|
.action(async (scope) => {
|
|
404
|
-
const targetScope = scope || await promptScope("Dockerfile", "~/.vibebox/Dockerfile", ".
|
|
398
|
+
const targetScope = scope || await promptScope("Dockerfile", "~/.vibebox/Dockerfile", "vibebox.Dockerfile");
|
|
405
399
|
if (targetScope !== "global" && targetScope !== "local") {
|
|
406
400
|
console.error("Usage: vibebox customize image <global|local>");
|
|
407
401
|
process.exit(1);
|
|
408
402
|
}
|
|
409
403
|
const isGlobal = targetScope === "global";
|
|
410
|
-
const file = (0, node_path_1.join)(
|
|
404
|
+
const file = isGlobal ? (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", "Dockerfile") : (0, node_path_1.join)(process.cwd(), "vibebox.Dockerfile");
|
|
411
405
|
openInEditor({ file, template: isGlobal ? TEMPLATES.imageGlobal : TEMPLATES.imageLocal });
|
|
412
|
-
console.log(`\nDockerfile
|
|
406
|
+
console.log(`\nDockerfile location: ${file}`);
|
|
413
407
|
console.log("Run 'vibebox rebuild' to apply changes.");
|
|
414
408
|
});
|
|
415
409
|
customize
|
|
416
410
|
.command("setup [scope]")
|
|
417
411
|
.description("Edit setup script (global or local)")
|
|
418
412
|
.action(async (scope) => {
|
|
419
|
-
const targetScope = scope || await promptScope("setup script", "~/.vibebox/setup.sh", ".
|
|
413
|
+
const targetScope = scope || await promptScope("setup script", "~/.vibebox/setup.sh", "vibebox.setup.sh");
|
|
420
414
|
if (targetScope !== "global" && targetScope !== "local") {
|
|
421
415
|
console.error("Usage: vibebox customize setup <global|local>");
|
|
422
416
|
process.exit(1);
|
|
423
417
|
}
|
|
424
418
|
const isGlobal = targetScope === "global";
|
|
425
|
-
const file = (0, node_path_1.join)(
|
|
419
|
+
const file = isGlobal ? (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", "setup.sh") : (0, node_path_1.join)(process.cwd(), "vibebox.setup.sh");
|
|
426
420
|
openInEditor({ file, template: isGlobal ? TEMPLATES.setupGlobal : TEMPLATES.setupLocal, isExecutable: true });
|
|
427
|
-
console.log(`\nSetup script
|
|
421
|
+
console.log(`\nSetup script location: ${file}`);
|
|
428
422
|
if (getSandboxName(process.cwd()))
|
|
429
423
|
console.log("Note: Runs on new sandboxes. To re-run: vibebox rm");
|
|
430
424
|
});
|
|
@@ -436,9 +430,74 @@ async function promptScope(type, globalPath, localPath) {
|
|
|
436
430
|
rl.close();
|
|
437
431
|
return answer.trim().toLowerCase();
|
|
438
432
|
}
|
|
433
|
+
// ============ Config Command ============
|
|
434
|
+
const config = commander_1.program.command("config").description("Manage sandbox configuration");
|
|
435
|
+
config
|
|
436
|
+
.command("sudo <on|off>")
|
|
437
|
+
.description("Enable or disable sudo access in sandbox (requires rebuild)")
|
|
438
|
+
.action((value) => {
|
|
439
|
+
const ws = process.cwd();
|
|
440
|
+
const cfg = loadConfig(ws);
|
|
441
|
+
if (value === "on") {
|
|
442
|
+
cfg.sudo = true;
|
|
443
|
+
saveConfig(ws, cfg);
|
|
444
|
+
console.log("Sudo enabled for this workspace");
|
|
445
|
+
}
|
|
446
|
+
else if (value === "off") {
|
|
447
|
+
cfg.sudo = false;
|
|
448
|
+
saveConfig(ws, cfg);
|
|
449
|
+
console.log("Sudo disabled for this workspace");
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
console.error("Usage: vibebox config sudo <on|off>");
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
if (getSandboxName(ws))
|
|
456
|
+
console.log("Restart for changes: vibebox stop && vibebox enter");
|
|
457
|
+
});
|
|
458
|
+
config
|
|
459
|
+
.command("show")
|
|
460
|
+
.description("Show current configuration")
|
|
461
|
+
.action(() => {
|
|
462
|
+
const ws = process.cwd();
|
|
463
|
+
const cfg = loadConfig(ws);
|
|
464
|
+
console.log("Workspace:", ws);
|
|
465
|
+
console.log("Config:", JSON.stringify(cfg, null, 2));
|
|
466
|
+
});
|
|
439
467
|
// ============ Help Display & Parse ============
|
|
440
468
|
// Only parse CLI when run directly (not when imported)
|
|
441
469
|
if (require.main === module) {
|
|
470
|
+
const warningFile = (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", ".accepted-warning");
|
|
471
|
+
if (!(0, node_fs_1.existsSync)(warningFile)) {
|
|
472
|
+
const red = "\x1b[31m";
|
|
473
|
+
const reset = "\x1b[0m";
|
|
474
|
+
console.log(`
|
|
475
|
+
${red}==================================
|
|
476
|
+
WARNING: EXPERIMENTAL SOFTWARE
|
|
477
|
+
==================================${reset}
|
|
478
|
+
|
|
479
|
+
This is alpha software with potential security holes.
|
|
480
|
+
Use at your own risk.
|
|
481
|
+
`);
|
|
482
|
+
const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
483
|
+
rl.question(`Type ${red}Accept${reset} to continue: `, (answer) => {
|
|
484
|
+
rl.close();
|
|
485
|
+
if (answer.toLowerCase() !== "accept") {
|
|
486
|
+
console.log("Goodbye.");
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const dir = (0, node_path_1.dirname)(warningFile);
|
|
490
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
491
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
492
|
+
(0, node_fs_1.writeFileSync)(warningFile, new Date().toISOString());
|
|
493
|
+
runCli();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
runCli();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function runCli() {
|
|
442
501
|
const isSubcommand = process.argv.length > 2 && !process.argv[2].startsWith("-");
|
|
443
502
|
if (process.argv.length === 2 ||
|
|
444
503
|
(!isSubcommand && (process.argv.includes("--help") || process.argv.includes("-h")))) {
|
|
@@ -448,12 +507,11 @@ if (require.main === module) {
|
|
|
448
507
|
}
|
|
449
508
|
// ============ Library code =========
|
|
450
509
|
// ============ Constants ============
|
|
451
|
-
const LOCKS_DIR = `${(0, node_os_1.homedir)()}/.vibebox/locks`;
|
|
452
|
-
const DETACHED_LOCK = `${LOCKS_DIR}/detached.lock`;
|
|
453
510
|
const WATCHER_SCRIPT = `${(0, node_os_1.homedir)()}/.local/bin/watcher.sh`;
|
|
454
511
|
const IDLE_TIMEOUT = 300; // 5 minutes
|
|
512
|
+
const HEARTBEAT_INTERVAL = 5000; // 5 seconds
|
|
455
513
|
function loadConfig(ws) {
|
|
456
|
-
const p = (0, node_path_1.join)(ws, ".
|
|
514
|
+
const p = (0, node_path_1.join)(ws, "vibebox.config.json");
|
|
457
515
|
if (!(0, node_fs_1.existsSync)(p))
|
|
458
516
|
return {};
|
|
459
517
|
try {
|
|
@@ -464,10 +522,7 @@ function loadConfig(ws) {
|
|
|
464
522
|
}
|
|
465
523
|
}
|
|
466
524
|
function saveConfig(ws, cfg) {
|
|
467
|
-
|
|
468
|
-
if (!(0, node_fs_1.existsSync)(dir))
|
|
469
|
-
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
470
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, "config.json"), JSON.stringify(cfg, null, 2));
|
|
525
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(ws, "vibebox.config.json"), JSON.stringify(cfg, null, 2));
|
|
471
526
|
}
|
|
472
527
|
function setupInstalledAgents({ workspace }) {
|
|
473
528
|
const installedAgentNames = (0, agents_1.detectInstalledAgents)();
|
|
@@ -479,40 +534,58 @@ function setupInstalledAgents({ workspace }) {
|
|
|
479
534
|
}
|
|
480
535
|
// ============ Helpers ============
|
|
481
536
|
function getWorkspaceInode(workspace) {
|
|
482
|
-
|
|
483
|
-
|
|
537
|
+
// Use execFileSync to avoid shell injection via workspace path
|
|
538
|
+
const args = process.platform === "darwin"
|
|
539
|
+
? ["-f", "%i", workspace]
|
|
540
|
+
: ["-c", "%i", workspace];
|
|
541
|
+
return (0, node_child_process_1.execFileSync)("stat", args, { encoding: "utf8" }).trim();
|
|
484
542
|
}
|
|
485
|
-
function withSessionLock({ name,
|
|
543
|
+
async function withSessionLock({ name, workspace, cmd, interactive = false }) {
|
|
486
544
|
const sessionId = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
487
|
-
|
|
488
|
-
(0,
|
|
489
|
-
(0,
|
|
490
|
-
|
|
491
|
-
|
|
545
|
+
// Use workspace's .vibebox/locks for session locks (writable)
|
|
546
|
+
const locksDir = (0, node_path_1.join)(workspace, ".vibebox", "locks");
|
|
547
|
+
const lockFile = (0, node_path_1.join)(locksDir, `session-${sessionId}.lock`);
|
|
548
|
+
const user = (0, node_os_1.userInfo)().username;
|
|
549
|
+
// Retry first exec to handle race between docker run and container readiness
|
|
550
|
+
const maxRetries = 20;
|
|
551
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
552
|
+
try {
|
|
553
|
+
(0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "mkdir", "-p", locksDir], { stdio: "pipe" });
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
const isNotRunning = e instanceof Error && e.message.includes("is not running");
|
|
558
|
+
if (attempt === maxRetries - 1 || !isNotRunning)
|
|
559
|
+
throw e;
|
|
560
|
+
(0, node_child_process_1.spawnSync)("sleep", ["0.1"]);
|
|
561
|
+
}
|
|
492
562
|
}
|
|
493
|
-
|
|
494
|
-
|
|
563
|
+
(0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "touch", lockFile], { stdio: "pipe" });
|
|
564
|
+
// Heartbeat: touch lock file periodically so watcher knows session is alive
|
|
565
|
+
const heartbeat = setInterval(() => {
|
|
495
566
|
try {
|
|
496
|
-
(0, node_child_process_1.execFileSync)("docker", ["exec",
|
|
567
|
+
(0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "touch", lockFile], { stdio: "pipe" });
|
|
497
568
|
}
|
|
498
569
|
catch { } // container stopped
|
|
499
|
-
|
|
500
|
-
|
|
570
|
+
}, HEARTBEAT_INTERVAL);
|
|
571
|
+
try {
|
|
572
|
+
// Use spawn (async) so event loop keeps running for heartbeat
|
|
573
|
+
const dockerArgs = ["exec", "-u", user, ...(interactive ? ["-it"] : []), name, ...cmd];
|
|
574
|
+
const child = (0, node_child_process_1.spawn)("docker", dockerArgs, { stdio: "inherit" });
|
|
575
|
+
const code = await new Promise((resolve) => {
|
|
576
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
577
|
+
});
|
|
578
|
+
return code;
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
clearInterval(heartbeat);
|
|
501
582
|
try {
|
|
502
|
-
|
|
503
|
-
const locks = out.split("\n").filter((f) => f.startsWith("session-") && f.endsWith(".lock"));
|
|
504
|
-
hasOthers = locks.filter((f) => f !== `session-${sessionId}.lock`).length > 0;
|
|
583
|
+
(0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "rm", "-f", lockFile], { stdio: "pipe" });
|
|
505
584
|
}
|
|
506
585
|
catch { } // container stopped
|
|
507
|
-
if (!hasOthers) {
|
|
508
|
-
try {
|
|
509
|
-
(0, node_child_process_1.execFileSync)("docker", ["exec", name, "rm", "-f", DETACHED_LOCK], { stdio: "pipe" });
|
|
510
|
-
}
|
|
511
|
-
catch { } // container stopped
|
|
512
|
-
}
|
|
513
586
|
}
|
|
514
587
|
}
|
|
515
|
-
function buildImage() {
|
|
588
|
+
function buildImage({ noCache = false } = {}) {
|
|
516
589
|
const info = (0, node_os_1.userInfo)();
|
|
517
590
|
const nodeVersion = (0, node_child_process_1.execSync)("node --version", { encoding: "utf8" }).trim().replace(/^v/, "");
|
|
518
591
|
const npmVersion = (0, node_child_process_1.execSync)("npm --version", { encoding: "utf8" }).trim();
|
|
@@ -527,6 +600,7 @@ function buildImage() {
|
|
|
527
600
|
const buildHash = (0, update_1.hashString)(buildFiles.map(f => (0, node_fs_1.readFileSync)((0, node_path_1.join)(contextDir, f), "utf8")).join("\n"));
|
|
528
601
|
const r = (0, node_child_process_1.spawnSync)("docker", [
|
|
529
602
|
"build", "-t", "vibebox",
|
|
603
|
+
...(noCache ? ["--no-cache"] : []),
|
|
530
604
|
"--build-arg", `NODE_VERSION=${nodeVersion}`,
|
|
531
605
|
"--build-arg", `NPM_VERSION=${npmVersion}`,
|
|
532
606
|
"--build-arg", `LOCAL_USER=${info.username}`,
|
|
@@ -549,28 +623,28 @@ function getImageHash(tag) {
|
|
|
549
623
|
return null;
|
|
550
624
|
}
|
|
551
625
|
}
|
|
552
|
-
function buildCustomImage({ tag,
|
|
553
|
-
|
|
554
|
-
if (!(0, node_fs_1.existsSync)(dockerfilePath))
|
|
626
|
+
function buildCustomImage({ tag, dockerfile, fromTag }) {
|
|
627
|
+
if (!(0, node_fs_1.existsSync)(dockerfile))
|
|
555
628
|
return false;
|
|
556
|
-
const content = (0, node_fs_1.readFileSync)(
|
|
629
|
+
const content = (0, node_fs_1.readFileSync)(dockerfile, "utf8");
|
|
557
630
|
const hash = (0, update_1.hashString)(content + fromTag);
|
|
558
631
|
if (getImageHash(tag) === hash)
|
|
559
632
|
return true; // Already up to date
|
|
560
633
|
console.log(`Building ${tag}...`);
|
|
561
|
-
const
|
|
634
|
+
const contextDir = (0, node_path_1.dirname)(dockerfile);
|
|
635
|
+
const r = (0, node_child_process_1.spawnSync)("docker", ["build", "-t", tag, "-f", dockerfile, "--label", `vibebox.hash=${hash}`, contextDir], { stdio: "inherit" });
|
|
562
636
|
return r.status === 0;
|
|
563
637
|
}
|
|
564
638
|
function getImageTag({ workspace }) {
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
const hasGlobal = (0, node_fs_1.existsSync)(
|
|
568
|
-
const hasLocal = (0, node_fs_1.existsSync)(
|
|
639
|
+
const globalDockerfile = (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", "Dockerfile");
|
|
640
|
+
const localDockerfile = (0, node_path_1.join)(workspace, "vibebox.Dockerfile");
|
|
641
|
+
const hasGlobal = (0, node_fs_1.existsSync)(globalDockerfile);
|
|
642
|
+
const hasLocal = (0, node_fs_1.existsSync)(localDockerfile);
|
|
569
643
|
const wsHash = (0, update_1.hashString)(workspace).slice(0, 8);
|
|
570
644
|
if (hasGlobal)
|
|
571
|
-
buildCustomImage({ tag: "vibebox:user",
|
|
645
|
+
buildCustomImage({ tag: "vibebox:user", dockerfile: globalDockerfile, fromTag: "vibebox" });
|
|
572
646
|
if (hasLocal)
|
|
573
|
-
buildCustomImage({ tag: `vibebox:${wsHash}`,
|
|
647
|
+
buildCustomImage({ tag: `vibebox:${wsHash}`, dockerfile: localDockerfile, fromTag: hasGlobal ? "vibebox:user" : "vibebox" });
|
|
574
648
|
if (hasLocal)
|
|
575
649
|
return `vibebox:${wsHash}`;
|
|
576
650
|
if (hasGlobal)
|
|
@@ -630,7 +704,7 @@ function writePortMappings(workspace, name) {
|
|
|
630
704
|
const parsed = [...mappings].map(([c, h]) => `${c}:${h}`).join("\n");
|
|
631
705
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(vibeboxDir, "port-mappings.txt"), parsed);
|
|
632
706
|
}
|
|
633
|
-
function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly = false, }) {
|
|
707
|
+
async function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly = false, }) {
|
|
634
708
|
const existing = getSandboxName(workspace);
|
|
635
709
|
if (existing) {
|
|
636
710
|
try {
|
|
@@ -641,13 +715,16 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
641
715
|
(0, node_child_process_1.execFileSync)("docker", ["start", existing], { stdio: "pipe" });
|
|
642
716
|
writePortMappings(workspace, existing);
|
|
643
717
|
}
|
|
644
|
-
// Verify container is
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
718
|
+
// Verify container is actually usable (not just "running" state but able to exec)
|
|
719
|
+
const user = (0, node_os_1.userInfo)().username;
|
|
720
|
+
const maxRetries = 10;
|
|
721
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
722
|
+
const result = (0, node_child_process_1.spawnSync)("docker", ["exec", "-u", user, existing, "true"], { stdio: "pipe" });
|
|
723
|
+
if (result.status === 0)
|
|
724
|
+
return existing;
|
|
725
|
+
(0, node_child_process_1.spawnSync)("sleep", ["0.2"]);
|
|
726
|
+
}
|
|
727
|
+
// Container not usable, remove and recreate
|
|
651
728
|
(0, node_child_process_1.execFileSync)("docker", ["rm", "-f", existing], { stdio: "pipe" });
|
|
652
729
|
}
|
|
653
730
|
catch {
|
|
@@ -670,10 +747,12 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
670
747
|
(0, node_fs_1.writeFileSync)(emptyFile, "");
|
|
671
748
|
const home = (0, node_os_1.homedir)();
|
|
672
749
|
const inode = getWorkspaceInode(workspace);
|
|
673
|
-
const
|
|
750
|
+
const cfg = loadConfig(workspace);
|
|
751
|
+
const ports = cfg.ports ?? DEFAULT_PORTS;
|
|
674
752
|
// ---- Agent args ----
|
|
675
753
|
const agentArgs = requestedAgents.flatMap((agent) => agent.dockerArgs({ workspace, home, containerOnly }));
|
|
676
754
|
// ---- Symlinks ----
|
|
755
|
+
// Scan for symlinks in mounted directories
|
|
677
756
|
const symlinkMounts = new Set();
|
|
678
757
|
const scanForSymlinks = (dir) => {
|
|
679
758
|
try {
|
|
@@ -703,17 +782,33 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
703
782
|
}
|
|
704
783
|
}
|
|
705
784
|
}
|
|
785
|
+
// If symlinks found, show them and ask user
|
|
786
|
+
if (symlinkMounts.size > 0) {
|
|
787
|
+
console.log("\nSymlinks found pointing outside workspace:");
|
|
788
|
+
for (const s of symlinkMounts)
|
|
789
|
+
console.log(` \x1b[2m${s}\x1b[0m`);
|
|
790
|
+
const mount = await promptConfirm("\nMount these paths?");
|
|
791
|
+
if (!mount) {
|
|
792
|
+
symlinkMounts.clear();
|
|
793
|
+
console.log("Symlinks not mounted.");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
706
796
|
// Agent names for label
|
|
707
797
|
const agentNames = requestedAgents.map((a) => a.name).join(",") || "none";
|
|
708
798
|
// Generate sandbox name
|
|
709
799
|
const now = new Date();
|
|
710
800
|
const name = `vibebox-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
|
|
711
801
|
// ---- SSH ----
|
|
712
|
-
//
|
|
802
|
+
// macOS: Docker Desktop runs in a VM, can't mount host socket directly.
|
|
803
|
+
// Use Docker's built-in forwarding via /run/host-services/ssh-auth.sock
|
|
804
|
+
// Linux: Mount host socket directly
|
|
805
|
+
// Windows: Named pipes (\\.\pipe\...) can't be mounted with -v
|
|
713
806
|
const sshAuthSock = process.env.SSH_AUTH_SOCK;
|
|
714
|
-
const sshArgs =
|
|
715
|
-
? ["-v",
|
|
716
|
-
:
|
|
807
|
+
const sshArgs = process.platform === "darwin" && sshAuthSock
|
|
808
|
+
? ["-v", "/run/host-services/ssh-auth.sock:/ssh-agent", "-e", "SSH_AUTH_SOCK=/ssh-agent"]
|
|
809
|
+
: process.platform !== "win32" && sshAuthSock && (0, node_fs_1.existsSync)(sshAuthSock)
|
|
810
|
+
? ["-v", `${sshAuthSock}:/ssh-agent`, "-e", "SSH_AUTH_SOCK=/ssh-agent"]
|
|
811
|
+
: [];
|
|
717
812
|
// Global vibebox config dir (for setup.sh)
|
|
718
813
|
const vibeboxGlobalDir = (0, node_path_1.join)(home, ".vibebox");
|
|
719
814
|
const vibeboxGlobalArgs = (0, node_fs_1.existsSync)(vibeboxGlobalDir)
|
|
@@ -760,10 +855,23 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
760
855
|
}
|
|
761
856
|
}
|
|
762
857
|
catch { } // no aliases
|
|
858
|
+
// ---- Security ----
|
|
859
|
+
const sudoArgs = cfg.sudo ? ["-e", "VIBEBOX_SUDO=1"] : [];
|
|
763
860
|
// ---- Docker run ----
|
|
764
861
|
const args = [
|
|
765
862
|
"run", "-d",
|
|
766
863
|
"--name", name,
|
|
864
|
+
// Security hardening: drop all capabilities, add back only what's needed
|
|
865
|
+
// See: man 7 capabilities
|
|
866
|
+
"--security-opt", "no-new-privileges",
|
|
867
|
+
"--cap-drop", "ALL",
|
|
868
|
+
// Required for entrypoint (runs as root, then drops to user via gosu):
|
|
869
|
+
"--cap-add", "CHOWN", // chown on SSH socket
|
|
870
|
+
"--cap-add", "SETUID", // gosu to switch user
|
|
871
|
+
"--cap-add", "SETGID", // gosu to switch group
|
|
872
|
+
"--cap-add", "DAC_OVERRIDE", // write to /etc/sudoers.d
|
|
873
|
+
"--cap-add", "FOWNER", // operate on files regardless of owner
|
|
874
|
+
"--cap-add", "KILL", // send signals to processes
|
|
767
875
|
// Labels for container lookup
|
|
768
876
|
"--label", "docker/sandbox=true",
|
|
769
877
|
"--label", `com.docker.sandbox.agent=${agentNames}`,
|
|
@@ -780,6 +888,8 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
780
888
|
// Environment
|
|
781
889
|
"-e", `TZ=${process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
|
782
890
|
"-e", `VIBEBOX_PROJECT_ROOT=${workspace}`,
|
|
891
|
+
// Sudo opt-in
|
|
892
|
+
...sudoArgs,
|
|
783
893
|
// Git config env vars
|
|
784
894
|
...gitEnvArgs,
|
|
785
895
|
// Agent-specific mounts
|
|
@@ -790,8 +900,8 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
|
|
|
790
900
|
"-v", `${workspace}:${workspace}`,
|
|
791
901
|
"-w", workspace,
|
|
792
902
|
imageTag,
|
|
793
|
-
//
|
|
794
|
-
|
|
903
|
+
// Command passed to entrypoint -> startup.sh
|
|
904
|
+
"bash", "-c", `${WATCHER_SCRIPT} ${IDLE_TIMEOUT}`,
|
|
795
905
|
];
|
|
796
906
|
const result = (0, node_child_process_1.spawnSync)("docker", args, { stdio: "pipe", encoding: "utf-8" });
|
|
797
907
|
if (result.status !== 0) {
|
package/dist/update.js
CHANGED
|
@@ -43,6 +43,7 @@ const node_readline_1 = require("node:readline");
|
|
|
43
43
|
const node_fs_1 = require("node:fs");
|
|
44
44
|
const node_crypto_1 = require("node:crypto");
|
|
45
45
|
const node_path_1 = require("node:path");
|
|
46
|
+
const node_os_1 = require("node:os");
|
|
46
47
|
function getVersion(cmd) {
|
|
47
48
|
const out = (0, node_child_process_1.execSync)(cmd, { encoding: "utf8" });
|
|
48
49
|
const match = out.match(/^(\d+\.\d+\.\d+(-[^\s]+)?)/);
|
|
@@ -101,7 +102,7 @@ function updateHost(agentCommand, version) {
|
|
|
101
102
|
}
|
|
102
103
|
function updateSandbox(sandboxName, agentCommand, version) {
|
|
103
104
|
console.log(`\nUpdating sandbox ${agentCommand}${version ? ` to ${version}` : " to latest"}...`);
|
|
104
|
-
const args = ["exec", "-it", sandboxName, agentCommand, "install"];
|
|
105
|
+
const args = ["exec", "-u", (0, node_os_1.userInfo)().username, "-it", sandboxName, agentCommand, "install"];
|
|
105
106
|
if (version)
|
|
106
107
|
args.push(version);
|
|
107
108
|
const result = (0, node_child_process_1.spawnSync)("docker", args, { stdio: "inherit" });
|
|
@@ -111,7 +112,8 @@ function updateSandbox(sandboxName, agentCommand, version) {
|
|
|
111
112
|
}
|
|
112
113
|
function verifyVersionsMatch({ sandboxName, versionCommand, successMsg }) {
|
|
113
114
|
const newHost = getVersion(versionCommand);
|
|
114
|
-
|
|
115
|
+
// Use zsh -lc to get login shell PATH (includes ~/.local/bin after install)
|
|
116
|
+
const newSandbox = getVersion(`docker exec -u ${(0, node_os_1.userInfo)().username} ${sandboxName} zsh -lc '${versionCommand}'`);
|
|
115
117
|
if (newHost === newSandbox) {
|
|
116
118
|
console.log(`\nSuccess! ${successMsg} ${newHost}`);
|
|
117
119
|
}
|
|
@@ -211,7 +213,7 @@ async function checkVersions(sandboxName, agent) {
|
|
|
211
213
|
const agentCommand = agent?.command ?? "claude";
|
|
212
214
|
try {
|
|
213
215
|
const host = getVersion(versionCommand);
|
|
214
|
-
const sandbox = getVersion(`docker exec ${sandboxName} ${versionCommand}`);
|
|
216
|
+
const sandbox = getVersion(`docker exec -u ${(0, node_os_1.userInfo)().username} ${sandboxName} zsh -lc '${versionCommand}'`);
|
|
215
217
|
if (host !== sandbox) {
|
|
216
218
|
const comparison = compareVersions(host, sandbox);
|
|
217
219
|
const higher = comparison >= 0 ? host : sandbox;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibebox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibebox": "./dist/index.js"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"postbuild": "chmod +x dist/index.js",
|
|
18
18
|
"build:image": "docker build -t vibebox .",
|
|
19
|
-
"rebuild": "
|
|
19
|
+
"rebuild": "npm run build && node scripts/update-build-hashes.mjs && vibebox rm --all && vibebox rebuild",
|
|
20
20
|
"preversion": "npm run build && node scripts/update-build-hashes.mjs",
|
|
21
21
|
"prepublishOnly": "npm run build",
|
|
22
22
|
"test": "vitest run",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"vibebox": {
|
|
34
34
|
"version": {
|
|
35
|
-
"build": "
|
|
35
|
+
"build": "90d18f8d395b9ad5"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|