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 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,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:** If `$SSH_AUTH_SOCK` exists, it's mounted into the container. Your SSH keys work without copying them.
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
- - `.vibebox/setup.sh` - Local, runs in this project sandbox only
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
- - `.vibebox/Dockerfile` - Project-specific (`FROM vibebox:user` or `FROM vibebox`)
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
- ### vs `docker sandbox`
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
- ### vs devcontainers
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
- - **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.
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
- 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)
@@ -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)) console.log(name, cmd);
25
- " 2>/dev/null | while read -r name cmd; do
26
- git config --global "alias.$name" "$cmd"
27
- done
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 .vibebox/setup.sh
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 [[ -x "$global_setup" ]]; then
75
+ if [[ -f "$global_setup" ]]; then
71
76
  echo "[vibebox] Running global setup..."
72
- "$global_setup" || echo "[vibebox] Global setup failed (continuing)"
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/.vibebox/setup.sh"
77
- if [[ -x "$local_setup" ]]; then
85
+ local local_setup="$WORKSPACE/vibebox.setup.sh"
86
+ if [[ -f "$local_setup" ]]; then
78
87
  echo "[vibebox] Running local setup..."
79
- "$local_setup" || echo "[vibebox] Local setup failed (continuing)"
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 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
512
+ const HEARTBEAT_INTERVAL = 5000; // 5 seconds
455
513
  function loadConfig(ws) {
456
- const p = (0, node_path_1.join)(ws, ".vibebox", "config.json");
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
- 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));
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
- const flag = process.platform === "darwin" ? "-f" : "-c";
483
- return (0, node_child_process_1.execSync)(`stat ${flag} %i "${workspace}"`, { encoding: "utf8" }).trim();
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, fn }) {
543
+ async function withSessionLock({ name, workspace, cmd, interactive = false }) {
486
544
  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();
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
- finally {
494
- // Remove this session's lock
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", name, "rm", "-f", lockFile], { stdio: "pipe" });
567
+ (0, node_child_process_1.execFileSync)("docker", ["exec", "-u", user, name, "touch", lockFile], { stdio: "pipe" });
497
568
  }
498
569
  catch { } // container stopped
499
- // Check if other sessions exist, remove detached lock if none
500
- let hasOthers = false;
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
- 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;
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, contextDir, fromTag }) {
553
- const dockerfilePath = (0, node_path_1.join)(contextDir, "Dockerfile");
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)(dockerfilePath, "utf8");
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 r = (0, node_child_process_1.spawnSync)("docker", ["build", "-t", tag, "--label", `vibebox.hash=${hash}`, contextDir], { stdio: "inherit" });
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 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"));
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", contextDir: globalDir, fromTag: "vibebox" });
645
+ buildCustomImage({ tag: "vibebox:user", dockerfile: globalDockerfile, fromTag: "vibebox" });
572
646
  if (hasLocal)
573
- buildCustomImage({ tag: `vibebox:${wsHash}`, contextDir: localDir, fromTag: hasGlobal ? "vibebox:user" : "vibebox" });
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 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
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 ports = loadConfig(workspace).ports ?? DEFAULT_PORTS;
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
- // Windows named pipes (\\.\pipe\...) can't be mounted with Docker -v
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 = sshAuthSock && process.platform !== "win32" && (0, node_fs_1.existsSync)(sshAuthSock)
715
- ? ["-v", `${sshAuthSock}:/ssh-agent`, "-e", "SSH_AUTH_SOCK=/ssh-agent"]
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
- // Run startup script with watcher
794
- `${home}/.local/bin/startup.sh`, "bash", "-c", `mkdir -p ${LOCKS_DIR} && touch ${DETACHED_LOCK} && ${WATCHER_SCRIPT} ${IDLE_TIMEOUT}`,
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
- 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.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": "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
  }