vibebox 0.0.2 → 0.0.4

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 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
- useradd -m -s /bin/zsh -u $LOCAL_UID -g $GROUP_NAME -d $LOCAL_HOME $LOCAL_USER && \
45
- echo "$LOCAL_USER ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
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 'export PS1="vibebox:%~ %(#.#.$) "' >> $LOCAL_HOME/.zshrc && \
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
- CMD ["/bin/zsh", "-c", "$HOME/.local/bin/startup.sh tail -f /dev/null"]
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 .vibebox/setup.sh (runs in this project)
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 .vibebox/Dockerfile (custom project-specific image)
49
+ vibebox customize image local # Edit vibebox.Dockerfile (custom project-specific image)
50
50
  ```
51
51
 
52
52
  ## Temporary Workspaces
@@ -100,9 +100,18 @@ 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:** If `$SSH_AUTH_SOCK` exists, it's mounted into the container. Your SSH keys work without copying them.
103
+ **SSH agent forwarding** and **GitHub CLI** are **disabled by default** for security (prevents prompt injection from using your credentials). Enable in `vibebox.config.json`:
104
104
 
105
- **GitHub CLI:** If `~/.config/gh/hosts.yml` exists, it's mounted into the container and `gh auth setup-git` runs automatically.
105
+ ```json
106
+ {
107
+ "sshAgent": true,
108
+ "ghAuth": true
109
+ }
110
+ ```
111
+
112
+ When enabled:
113
+ - **SSH agent:** Forwarded to container. Keys never leave the host. Works with Docker Desktop (macOS) and native Docker (Linux).
114
+ - **GitHub CLI:** `~/.config/gh/hosts.yml` mounted read-only, `gh auth setup-git` runs automatically.
106
115
 
107
116
  **HTTPS hint:** If using HTTPS remotes with SSH available, you'll see:
108
117
  ```
@@ -114,42 +123,43 @@ Git config is automatically synced from host (user.name, user.email, aliases, co
114
123
 
115
124
  **Setup scripts** run once when a container is first created:
116
125
  - `~/.vibebox/setup.sh` - Global, runs in all sandboxes
117
- - `.vibebox/setup.sh` - Local, runs in this project sandbox only
126
+ - `vibebox.setup.sh` - Local, runs in this project sandbox only
118
127
 
119
128
  **Custom Dockerfiles** extend the base image:
120
129
  - `~/.vibebox/Dockerfile` - Global customizations (`FROM vibebox`)
121
- - `.vibebox/Dockerfile` - Project-specific (`FROM vibebox:user` or `FROM vibebox`)
130
+ - `vibebox.Dockerfile` - Project-specific (`FROM vibebox:user` or `FROM vibebox`)
122
131
 
123
- Image hierarchy: `vibebox` `vibebox:user` (global) `vibebox:<hash>` (local)
132
+ **Config** is stored in `vibebox.config.json` at project root and `~/.vibebox/config.json` globally. Local overrides global.
124
133
 
125
- Run `vibebox rebuild` after editing Dockerfiles.
134
+ ```json
135
+ {
136
+ "ports": ["3000", "5173"],
137
+ "sudo": false,
138
+ "sshAgent": false,
139
+ "ghAuth": false
140
+ }
141
+ ```
126
142
 
127
- ## Why vibebox?
143
+ | Option | Default | Description |
144
+ |--------|---------|-------------|
145
+ | `ports` | `["3000", "3001", ...]` | Ports to expose from sandbox |
146
+ | `sudo` | `false` | Enable sudo access in sandbox |
147
+ | `sshAgent` | `false` | Forward SSH agent to sandbox |
148
+ | `ghAuth` | `false` | Mount GitHub CLI credentials |
128
149
 
129
- ### vs `docker sandbox`
150
+ All `vibebox.*` files at project root are meant to be git-tracked. The `.vibebox/` folder contains only runtime artifacts and can be gitignored.
130
151
 
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:
152
+ Image hierarchy: `vibebox` `vibebox:user` (global) `vibebox:<hash>` (local)
132
153
 
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.
154
+ Run `vibebox rebuild` after editing Dockerfiles.
140
155
 
141
- ### vs raw docker
156
+ ## Why vibebox?
142
157
 
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.
158
+ **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
159
 
148
- ### vs devcontainers
160
+ **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
161
 
150
- - **No IDE coupling**: Devcontainers are designed for VS Code. vibebox works from any terminal.
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.
162
+ **vs devcontainers**: No VS Code dependency, no `devcontainer.json`. Built for CLI agents, not general dev environments.
153
163
 
154
164
  ## License
155
165
 
@@ -0,0 +1,43 @@
1
+ # custom #
2
+ ###################
3
+ .vibebox
4
+
5
+
6
+ # Compiled source #
7
+ ###################
8
+ *.com
9
+ *.class
10
+ *.dll
11
+ *.exe
12
+ *.o
13
+ *.so
14
+
15
+ # Packages #
16
+ ############
17
+ # it's better to unpack these files and commit the raw source
18
+ # git has its own built in compression methods
19
+ *.7z
20
+ *.dmg
21
+ *.gz
22
+ *.iso
23
+ *.jar
24
+ *.rar
25
+ *.tar
26
+ *.zip
27
+
28
+ # Logs and databases #
29
+ ######################
30
+ *.log
31
+ *.sql
32
+ *.sqlite
33
+ !**/prisma/**/*.sql
34
+
35
+ # OS generated files #
36
+ ######################
37
+ .DS_Store
38
+ .DS_Store?
39
+ ._*
40
+ .Spotlight-V100
41
+ .Trashes
42
+ ehthumbs.db
43
+ Thumbs.db
@@ -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
- SETUP_MARKER="$HOME_DIR/.vibebox/.setup-done-$(echo "$WORKSPACE" | tr '/' '-')"
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)
@@ -17,14 +18,21 @@ setup_git_config() {
17
18
  [[ -n "$VIBEBOX_GIT_PULL_REBASE" ]] && git config --global pull.rebase "$VIBEBOX_GIT_PULL_REBASE"
18
19
  [[ -n "$VIBEBOX_GIT_HELP_AUTOCORRECT" ]] && git config --global help.autocorrect "$VIBEBOX_GIT_HELP_AUTOCORRECT"
19
20
 
21
+ # Default global gitignore (shipped with vibebox)
22
+ git config --global core.excludesFile "$HOME_DIR/.local/bin/gitignore_global"
23
+
20
24
  # Apply git aliases (passed as JSON in VIBEBOX_GIT_ALIASES)
25
+ # Node calls git config directly to avoid shell injection via alias values
21
26
  if [[ -n "$VIBEBOX_GIT_ALIASES" ]]; then
22
27
  echo "$VIBEBOX_GIT_ALIASES" | node -e "
28
+ const { execFileSync } = require('child_process');
23
29
  const aliases = JSON.parse(require('fs').readFileSync(0, 'utf8'));
24
- for (const [name, cmd] of Object.entries(aliases)) console.log(name, cmd);
25
- " 2>/dev/null | while read -r name cmd; do
26
- git config --global "alias.$name" "$cmd"
27
- done
30
+ for (const [name, cmd] of Object.entries(aliases)) {
31
+ try {
32
+ execFileSync('git', ['config', '--global', 'alias.' + name, cmd], { stdio: 'pipe' });
33
+ } catch {}
34
+ }
35
+ " 2>/dev/null || true
28
36
  fi
29
37
  }
30
38
 
@@ -58,7 +66,7 @@ show_ssh_hint() {
58
66
  }
59
67
 
60
68
  # ============ User Setup Scripts ============
61
- # Run once per container: global ~/.vibebox/setup.sh then local .vibebox/setup.sh
69
+ # Run once per container: global ~/.vibebox/setup.sh then local vibebox.setup.sh
62
70
 
63
71
  run_setup_scripts() {
64
72
  if [[ -f "$SETUP_MARKER" ]]; then
@@ -67,35 +75,33 @@ run_setup_scripts() {
67
75
 
68
76
  # Global setup (runs in all sandboxes)
69
77
  local global_setup="$HOME_DIR/.vibebox/setup.sh"
70
- if [[ -x "$global_setup" ]]; then
78
+ if [[ -f "$global_setup" ]]; then
71
79
  echo "[vibebox] Running global setup..."
72
- "$global_setup" || echo "[vibebox] Global setup failed (continuing)"
80
+ if [[ -x "$global_setup" ]]; then
81
+ "$global_setup" || echo "[vibebox] Global setup failed (continuing)"
82
+ else
83
+ zsh "$global_setup" || echo "[vibebox] Global setup failed (continuing)"
84
+ fi
73
85
  fi
74
86
 
75
87
  # Local setup (project-specific)
76
- local local_setup="$WORKSPACE/.vibebox/setup.sh"
77
- if [[ -x "$local_setup" ]]; then
88
+ local local_setup="$WORKSPACE/vibebox.setup.sh"
89
+ if [[ -f "$local_setup" ]]; then
78
90
  echo "[vibebox] Running local setup..."
79
- "$local_setup" || echo "[vibebox] Local setup failed (continuing)"
91
+ if [[ -x "$local_setup" ]]; then
92
+ "$local_setup" || echo "[vibebox] Local setup failed (continuing)"
93
+ else
94
+ zsh "$local_setup" || echo "[vibebox] Local setup failed (continuing)"
95
+ fi
80
96
  fi
81
97
 
82
98
  mkdir -p "$(dirname "$SETUP_MARKER")"
83
99
  touch "$SETUP_MARKER"
84
100
  }
85
101
 
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
102
  # ============ Main ============
96
103
 
97
104
  # Run setup on container start
98
- fix_ssh_socket
99
105
  setup_git_config
100
106
  setup_gh_cli
101
107
  run_setup_scripts
@@ -1,45 +1,54 @@
1
1
  #!/bin/bash
2
- # Idle watcher - exits after TIMEOUT seconds of no lock files (except detached.lock)
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
- LOCKS_DIR="$HOME/.vibebox/locks"
5
- DETACHED_LOCK="$LOCKS_DIR/detached.lock"
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
- # If detached.lock exists, loop forever
16
- if [ -f "$DETACHED_LOCK" ]; then
17
- rm -f "$IDLE_FILE"
18
- sleep 1
19
- continue
20
- fi
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
- # Count session lock files (session-*.lock)
23
- lock_count=$(find "$LOCKS_DIR" -name 'session-*.lock' 2>/dev/null | wc -l)
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
- # Start idle timer
38
- date +%s > "$IDLE_FILE"
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
- # Sessions active - reset idle timer
42
- rm -f "$IDLE_FILE"
50
+ # Active sessions - reset idle timer
51
+ idle_start=""
43
52
  fi
44
53
 
45
54
  sleep 1
@@ -60,7 +60,16 @@ function getCredentialStore() {
60
60
  }
61
61
  },
62
62
  set: (service, account, value) => {
63
- (0, node_child_process_1.execSync)(`echo -n "${value}" | secret-tool store --label="${service}" service "${service}" account "${account}"`);
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 and trust prompts
123
- const configCopy = (0, node_path_1.join)(workspace, ".vibebox", "claude.json");
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)(configCopy, JSON.stringify(minimalConfig, null, 2));
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;
@@ -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
- try {
18
- (0, node_child_process_1.execSync)(`docker exec ${containerName} which ${agent.command}`, { stdio: "pipe" });
19
- return true;
20
- }
21
- catch {
22
- return false;
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 r = withSessionLock({
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(r.status ?? 0);
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 r = withSessionLock({
182
- name,
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
- .action(async () => {
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
- try {
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 = ["5173", "3000", "3001", "4173", "8080"];
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("Restart for changes: vibebox stop && vibebox claude");
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("Restart for changes: vibebox stop && vibebox claude");
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/bash
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
- # Example: sudo apt-get update && sudo apt-get install -y ripgrep
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/bash
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=...' >> ~/.bashrc
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", ".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)(isGlobal ? (0, node_os_1.homedir)() : process.cwd(), ".vibebox", "Dockerfile");
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 saved: ${file}`);
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", ".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)(isGlobal ? (0, node_os_1.homedir)() : process.cwd(), ".vibebox", "setup.sh");
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 saved: ${file}`);
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
455
- function loadConfig(ws) {
456
- const p = (0, node_path_1.join)(ws, ".vibebox", "config.json");
512
+ const HEARTBEAT_INTERVAL = 5000; // 5 seconds
513
+ function loadGlobalConfig() {
514
+ const p = (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", "config.json");
457
515
  if (!(0, node_fs_1.existsSync)(p))
458
516
  return {};
459
517
  try {
@@ -463,11 +521,28 @@ function loadConfig(ws) {
463
521
  return {};
464
522
  }
465
523
  }
524
+ function loadLocalConfig(ws) {
525
+ const p = (0, node_path_1.join)(ws, "vibebox.config.json");
526
+ if (!(0, node_fs_1.existsSync)(p))
527
+ return {};
528
+ try {
529
+ return JSON.parse((0, node_fs_1.readFileSync)(p, "utf8"));
530
+ }
531
+ catch {
532
+ return {};
533
+ }
534
+ }
535
+ function loadConfig(ws) {
536
+ const global = loadGlobalConfig();
537
+ const local = loadLocalConfig(ws);
538
+ // Local overrides global (undefined means "use global default")
539
+ return {
540
+ ...global,
541
+ ...Object.fromEntries(Object.entries(local).filter(([_, v]) => v !== undefined)),
542
+ };
543
+ }
466
544
  function saveConfig(ws, cfg) {
467
- const dir = (0, node_path_1.join)(ws, ".vibebox");
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));
545
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(ws, "vibebox.config.json"), JSON.stringify(cfg, null, 2));
471
546
  }
472
547
  function setupInstalledAgents({ workspace }) {
473
548
  const installedAgentNames = (0, agents_1.detectInstalledAgents)();
@@ -479,40 +554,58 @@ function setupInstalledAgents({ workspace }) {
479
554
  }
480
555
  // ============ Helpers ============
481
556
  function getWorkspaceInode(workspace) {
482
- const flag = process.platform === "darwin" ? "-f" : "-c";
483
- return (0, node_child_process_1.execSync)(`stat ${flag} %i "${workspace}"`, { encoding: "utf8" }).trim();
557
+ // Use execFileSync to avoid shell injection via workspace path
558
+ const args = process.platform === "darwin"
559
+ ? ["-f", "%i", workspace]
560
+ : ["-c", "%i", workspace];
561
+ return (0, node_child_process_1.execFileSync)("stat", args, { encoding: "utf8" }).trim();
484
562
  }
485
- function withSessionLock({ name, fn }) {
563
+ async function withSessionLock({ name, workspace, cmd, interactive = false }) {
486
564
  const sessionId = (0, node_crypto_1.randomBytes)(8).toString("hex");
487
- const lockFile = `${LOCKS_DIR}/session-${sessionId}.lock`;
488
- (0, node_child_process_1.execFileSync)("docker", ["exec", name, "mkdir", "-p", LOCKS_DIR], { stdio: "pipe" });
489
- (0, node_child_process_1.execFileSync)("docker", ["exec", name, "touch", lockFile], { stdio: "pipe" });
490
- try {
491
- return fn();
565
+ // Use workspace's .vibebox/locks for session locks (writable)
566
+ const locksDir = (0, node_path_1.join)(workspace, ".vibebox", "locks");
567
+ const lockFile = (0, node_path_1.join)(locksDir, `session-${sessionId}.lock`);
568
+ const user = (0, node_os_1.userInfo)().username;
569
+ // Retry first exec to handle race between docker run and container readiness
570
+ const maxRetries = 20;
571
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
572
+ try {
573
+ (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "mkdir", "-p", locksDir], { stdio: "pipe" });
574
+ break;
575
+ }
576
+ catch (e) {
577
+ const isNotRunning = e instanceof Error && e.message.includes("is not running");
578
+ if (attempt === maxRetries - 1 || !isNotRunning)
579
+ throw e;
580
+ (0, node_child_process_1.spawnSync)("sleep", ["0.1"]);
581
+ }
492
582
  }
493
- finally {
494
- // Remove this session's lock
583
+ (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "touch", lockFile], { stdio: "pipe" });
584
+ // Heartbeat: touch lock file periodically so watcher knows session is alive
585
+ const heartbeat = setInterval(() => {
495
586
  try {
496
- (0, node_child_process_1.execFileSync)("docker", ["exec", name, "rm", "-f", lockFile], { stdio: "pipe" });
587
+ (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "touch", lockFile], { stdio: "pipe" });
497
588
  }
498
589
  catch { } // container stopped
499
- // Check if other sessions exist, remove detached lock if none
500
- let hasOthers = false;
590
+ }, HEARTBEAT_INTERVAL);
591
+ try {
592
+ // Use spawn (async) so event loop keeps running for heartbeat
593
+ const dockerArgs = ["exec", "-u", user, ...(interactive ? ["-it"] : []), name, ...cmd];
594
+ const child = (0, node_child_process_1.spawn)("docker", dockerArgs, { stdio: "inherit" });
595
+ const code = await new Promise((resolve) => {
596
+ child.on("close", (code) => resolve(code ?? 0));
597
+ });
598
+ return code;
599
+ }
600
+ finally {
601
+ clearInterval(heartbeat);
501
602
  try {
502
- const out = (0, node_child_process_1.execFileSync)("docker", ["exec", name, "ls", LOCKS_DIR], { encoding: "utf8" });
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;
603
+ (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "rm", "-f", lockFile], { stdio: "pipe" });
505
604
  }
506
605
  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
606
  }
514
607
  }
515
- function buildImage() {
608
+ function buildImage({ noCache = false } = {}) {
516
609
  const info = (0, node_os_1.userInfo)();
517
610
  const nodeVersion = (0, node_child_process_1.execSync)("node --version", { encoding: "utf8" }).trim().replace(/^v/, "");
518
611
  const npmVersion = (0, node_child_process_1.execSync)("npm --version", { encoding: "utf8" }).trim();
@@ -527,6 +620,7 @@ function buildImage() {
527
620
  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
621
  const r = (0, node_child_process_1.spawnSync)("docker", [
529
622
  "build", "-t", "vibebox",
623
+ ...(noCache ? ["--no-cache"] : []),
530
624
  "--build-arg", `NODE_VERSION=${nodeVersion}`,
531
625
  "--build-arg", `NPM_VERSION=${npmVersion}`,
532
626
  "--build-arg", `LOCAL_USER=${info.username}`,
@@ -549,28 +643,28 @@ function getImageHash(tag) {
549
643
  return null;
550
644
  }
551
645
  }
552
- function buildCustomImage({ tag, contextDir, fromTag }) {
553
- const dockerfilePath = (0, node_path_1.join)(contextDir, "Dockerfile");
554
- if (!(0, node_fs_1.existsSync)(dockerfilePath))
646
+ function buildCustomImage({ tag, dockerfile, fromTag }) {
647
+ if (!(0, node_fs_1.existsSync)(dockerfile))
555
648
  return false;
556
- const content = (0, node_fs_1.readFileSync)(dockerfilePath, "utf8");
649
+ const content = (0, node_fs_1.readFileSync)(dockerfile, "utf8");
557
650
  const hash = (0, update_1.hashString)(content + fromTag);
558
651
  if (getImageHash(tag) === hash)
559
652
  return true; // Already up to date
560
653
  console.log(`Building ${tag}...`);
561
- const r = (0, node_child_process_1.spawnSync)("docker", ["build", "-t", tag, "--label", `vibebox.hash=${hash}`, contextDir], { stdio: "inherit" });
654
+ const contextDir = (0, node_path_1.dirname)(dockerfile);
655
+ const r = (0, node_child_process_1.spawnSync)("docker", ["build", "-t", tag, "-f", dockerfile, "--label", `vibebox.hash=${hash}`, contextDir], { stdio: "inherit" });
562
656
  return r.status === 0;
563
657
  }
564
658
  function getImageTag({ workspace }) {
565
- const globalDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox");
566
- const localDir = (0, node_path_1.join)(workspace, ".vibebox");
567
- const hasGlobal = (0, node_fs_1.existsSync)((0, node_path_1.join)(globalDir, "Dockerfile"));
568
- const hasLocal = (0, node_fs_1.existsSync)((0, node_path_1.join)(localDir, "Dockerfile"));
659
+ const globalDockerfile = (0, node_path_1.join)((0, node_os_1.homedir)(), ".vibebox", "Dockerfile");
660
+ const localDockerfile = (0, node_path_1.join)(workspace, "vibebox.Dockerfile");
661
+ const hasGlobal = (0, node_fs_1.existsSync)(globalDockerfile);
662
+ const hasLocal = (0, node_fs_1.existsSync)(localDockerfile);
569
663
  const wsHash = (0, update_1.hashString)(workspace).slice(0, 8);
570
664
  if (hasGlobal)
571
- buildCustomImage({ tag: "vibebox:user", contextDir: globalDir, fromTag: "vibebox" });
665
+ buildCustomImage({ tag: "vibebox:user", dockerfile: globalDockerfile, fromTag: "vibebox" });
572
666
  if (hasLocal)
573
- buildCustomImage({ tag: `vibebox:${wsHash}`, contextDir: localDir, fromTag: hasGlobal ? "vibebox:user" : "vibebox" });
667
+ buildCustomImage({ tag: `vibebox:${wsHash}`, dockerfile: localDockerfile, fromTag: hasGlobal ? "vibebox:user" : "vibebox" });
574
668
  if (hasLocal)
575
669
  return `vibebox:${wsHash}`;
576
670
  if (hasGlobal)
@@ -630,7 +724,7 @@ function writePortMappings(workspace, name) {
630
724
  const parsed = [...mappings].map(([c, h]) => `${c}:${h}`).join("\n");
631
725
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(vibeboxDir, "port-mappings.txt"), parsed);
632
726
  }
633
- function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly = false, }) {
727
+ async function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly = false, }) {
634
728
  const existing = getSandboxName(workspace);
635
729
  if (existing) {
636
730
  try {
@@ -641,13 +735,16 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
641
735
  (0, node_child_process_1.execFileSync)("docker", ["start", existing], { stdio: "pipe" });
642
736
  writePortMappings(workspace, existing);
643
737
  }
644
- // Verify container is now running
645
- const newState = (0, node_child_process_1.execFileSync)("docker", ["inspect", existing, "--format={{.State.Running}}"], {
646
- encoding: "utf8",
647
- }).trim();
648
- if (newState === "true")
649
- return existing;
650
- // Container failed to start, remove it and create fresh
738
+ // Verify container is actually usable (not just "running" state but able to exec)
739
+ const user = (0, node_os_1.userInfo)().username;
740
+ const maxRetries = 10;
741
+ for (let i = 0; i < maxRetries; i++) {
742
+ const result = (0, node_child_process_1.spawnSync)("docker", ["exec", "-u", user, existing, "true"], { stdio: "pipe" });
743
+ if (result.status === 0)
744
+ return existing;
745
+ (0, node_child_process_1.spawnSync)("sleep", ["0.2"]);
746
+ }
747
+ // Container not usable, remove and recreate
651
748
  (0, node_child_process_1.execFileSync)("docker", ["rm", "-f", existing], { stdio: "pipe" });
652
749
  }
653
750
  catch {
@@ -670,10 +767,12 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
670
767
  (0, node_fs_1.writeFileSync)(emptyFile, "");
671
768
  const home = (0, node_os_1.homedir)();
672
769
  const inode = getWorkspaceInode(workspace);
673
- const ports = loadConfig(workspace).ports ?? DEFAULT_PORTS;
770
+ const cfg = loadConfig(workspace);
771
+ const ports = cfg.ports ?? DEFAULT_PORTS;
674
772
  // ---- Agent args ----
675
773
  const agentArgs = requestedAgents.flatMap((agent) => agent.dockerArgs({ workspace, home, containerOnly }));
676
774
  // ---- Symlinks ----
775
+ // Scan for symlinks in mounted directories
677
776
  const symlinkMounts = new Set();
678
777
  const scanForSymlinks = (dir) => {
679
778
  try {
@@ -703,27 +802,46 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
703
802
  }
704
803
  }
705
804
  }
805
+ // If symlinks found, show them and ask user
806
+ if (symlinkMounts.size > 0) {
807
+ console.log("\nSymlinks found pointing outside workspace:");
808
+ for (const s of symlinkMounts)
809
+ console.log(` \x1b[2m${s}\x1b[0m`);
810
+ const mount = await promptConfirm("\nMount these paths?");
811
+ if (!mount) {
812
+ symlinkMounts.clear();
813
+ console.log("Symlinks not mounted.");
814
+ }
815
+ }
706
816
  // Agent names for label
707
817
  const agentNames = requestedAgents.map((a) => a.name).join(",") || "none";
708
818
  // Generate sandbox name
709
819
  const now = new Date();
710
820
  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
821
  // ---- SSH ----
712
- // Windows named pipes (\\.\pipe\...) can't be mounted with Docker -v
822
+ // Opt-in via config (default: false)
823
+ // macOS: Docker Desktop runs in a VM, can't mount host socket directly.
824
+ // Use Docker's built-in forwarding via /run/host-services/ssh-auth.sock
825
+ // Linux: Mount host socket directly
826
+ // Windows: Named pipes (\\.\pipe\...) can't be mounted with -v
713
827
  const sshAuthSock = process.env.SSH_AUTH_SOCK;
714
- const sshArgs = sshAuthSock && process.platform !== "win32" && (0, node_fs_1.existsSync)(sshAuthSock)
715
- ? ["-v", `${sshAuthSock}:/ssh-agent`, "-e", "SSH_AUTH_SOCK=/ssh-agent"]
716
- : [];
828
+ const sshArgs = !cfg.sshAgent ? [] :
829
+ process.platform === "darwin" && sshAuthSock
830
+ ? ["-v", "/run/host-services/ssh-auth.sock:/ssh-agent", "-e", "SSH_AUTH_SOCK=/ssh-agent"]
831
+ : process.platform !== "win32" && sshAuthSock && (0, node_fs_1.existsSync)(sshAuthSock)
832
+ ? ["-v", `${sshAuthSock}:/ssh-agent`, "-e", "SSH_AUTH_SOCK=/ssh-agent"]
833
+ : [];
717
834
  // Global vibebox config dir (for setup.sh)
718
835
  const vibeboxGlobalDir = (0, node_path_1.join)(home, ".vibebox");
719
836
  const vibeboxGlobalArgs = (0, node_fs_1.existsSync)(vibeboxGlobalDir)
720
837
  ? ["-v", `${vibeboxGlobalDir}:${vibeboxGlobalDir}:ro`]
721
838
  : [];
722
839
  // ---- GitHub CLI ----
840
+ // Opt-in via config (default: false)
723
841
  const ghConfigDir = process.platform === "win32" && process.env.APPDATA
724
842
  ? (0, node_path_1.join)(process.env.APPDATA, "GitHub CLI")
725
843
  : (0, node_path_1.join)(home, ".config", "gh");
726
- const ghArgs = (0, node_fs_1.existsSync)((0, node_path_1.join)(ghConfigDir, "hosts.yml"))
844
+ const ghArgs = cfg.ghAuth && (0, node_fs_1.existsSync)((0, node_path_1.join)(ghConfigDir, "hosts.yml"))
727
845
  ? ["-v", `${ghConfigDir}:${(0, node_path_1.join)(home, ".config", "gh")}:ro`]
728
846
  : [];
729
847
  // ---- Git config ----
@@ -760,10 +878,23 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
760
878
  }
761
879
  }
762
880
  catch { } // no aliases
881
+ // ---- Security ----
882
+ const sudoArgs = cfg.sudo ? ["-e", "VIBEBOX_SUDO=1"] : [];
763
883
  // ---- Docker run ----
764
884
  const args = [
765
885
  "run", "-d",
766
886
  "--name", name,
887
+ // Security hardening: drop all capabilities, add back only what's needed
888
+ // See: man 7 capabilities
889
+ "--security-opt", "no-new-privileges",
890
+ "--cap-drop", "ALL",
891
+ // Required for entrypoint (runs as root, then drops to user via gosu):
892
+ "--cap-add", "CHOWN", // chown on SSH socket
893
+ "--cap-add", "SETUID", // gosu to switch user
894
+ "--cap-add", "SETGID", // gosu to switch group
895
+ "--cap-add", "DAC_OVERRIDE", // write to /etc/sudoers.d
896
+ "--cap-add", "FOWNER", // operate on files regardless of owner
897
+ "--cap-add", "KILL", // send signals to processes
767
898
  // Labels for container lookup
768
899
  "--label", "docker/sandbox=true",
769
900
  "--label", `com.docker.sandbox.agent=${agentNames}`,
@@ -780,6 +911,8 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
780
911
  // Environment
781
912
  "-e", `TZ=${process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone}`,
782
913
  "-e", `VIBEBOX_PROJECT_ROOT=${workspace}`,
914
+ // Sudo opt-in
915
+ ...sudoArgs,
783
916
  // Git config env vars
784
917
  ...gitEnvArgs,
785
918
  // Agent-specific mounts
@@ -790,8 +923,8 @@ function ensureSandbox({ workspace, agents: requestedAgents = [], containerOnly
790
923
  "-v", `${workspace}:${workspace}`,
791
924
  "-w", workspace,
792
925
  imageTag,
793
- // Run startup script with watcher
794
- `${home}/.local/bin/startup.sh`, "bash", "-c", `mkdir -p ${LOCKS_DIR} && touch ${DETACHED_LOCK} && ${WATCHER_SCRIPT} ${IDLE_TIMEOUT}`,
926
+ // Command passed to entrypoint -> startup.sh
927
+ "bash", "-c", `${WATCHER_SCRIPT} ${IDLE_TIMEOUT}`,
795
928
  ];
796
929
  const result = (0, node_child_process_1.spawnSync)("docker", args, { stdio: "pipe", encoding: "utf-8" });
797
930
  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
- const newSandbox = getVersion(`docker exec ${sandboxName} ${versionCommand}`);
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.2",
3
+ "version": "0.0.4",
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": "tsc && node scripts/update-build-hashes.mjs && vibebox rm --all && vibebox 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": "bc83a65b541a24ce"
35
+ "build": "90d18f8d395b9ad5"
36
36
  }
37
37
  }
38
38
  }