Sandbox Port Exposure Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add CLI commands to forward host ports to a running sandbox using socat.

Architecture: Three new bb tasks (expose, unexpose, exposed) in scripts/sandbox.clj. State tracked in .worktrees/.sandbox-expose/. Uses babashka.process to spawn/kill socat processes.

Tech Stack: Babashka, socat (host dependency), Docker CLI


Task 1: Add state directory constant and port list

Files:

Step 1: Add constants after existing defs

Add after line 13 (after port-range-size):

(def expose-state-dir ".worktrees/.sandbox-expose")
(def expose-ports [7777 8888 9999 7888])

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): add expose state dir and port constants"

Task 2: Add helper to get container IP

Files:

Step 1: Add function after parse-args

Add after parse-args function (after line 41):

(defn get-container-ip
  "Get IP address of a running container by name pattern"
  [container-name]
  (let [result (p/shell {:out :string :continue true}
                        "docker" "inspect" "-f"
                        "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
                        container-name)]
    (when (zero? (:exit result))
      (str/trim (:out result)))))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): add get-container-ip helper"

Task 3: Add helper to find claude container for a feature

Files:

Step 1: Add function after get-container-ip

(defn find-claude-container
  "Find the claude container name for a feature, or nil if not running"
  [feature]
  (let [result (p/shell {:out :string :continue true}
                        "docker" "ps" "-q" "-f"
                        (str "name=orcha-sandbox-" feature "-claude"))]
    (when (and (zero? (:exit result))
               (not (str/blank? (:out result))))
      (str/trim (:out result)))))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): add find-claude-container helper"

Task 4: Add helper to kill existing socat processes

Files:

Step 1: Add function after find-claude-container

(defn kill-existing-expose!
  "Kill any existing socat processes from previous expose"
  []
  (let [pids-file (str (fs/path expose-state-dir "pids"))]
    (when (fs/exists? pids-file)
      (doseq [pid (str/split (slurp pids-file) #"\s+")]
        (when-not (str/blank? pid)
          (p/shell {:continue true} "kill" pid)))
      (fs/delete pids-file))
    (when (fs/exists? (str (fs/path expose-state-dir "current")))
      (fs/delete (str (fs/path expose-state-dir "current"))))))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): add kill-existing-expose! helper"

Task 5: Implement expose function

Files:

Step 1: Add expose function after kill-existing-expose!

(defn expose
  "Expose sandbox ports to host via socat"
  [args]
  (let [{:keys [feature]} (parse-args args)]
    (when-not feature
      (println "Usage: bb sandbox:expose <feature-name>")
      (System/exit 1))

    (let [container-id (find-claude-container feature)]
      (when-not container-id
        (println (str "Error: sandbox '" feature "' is not running"))
        (System/exit 1))

      (let [container-ip (get-container-ip container-id)]
        (when-not container-ip
          (println "Error: could not get container IP")
          (System/exit 1))

        ;; Kill any existing expose
        (kill-existing-expose!)

        ;; Create state directory
        (fs/create-dirs expose-state-dir)

        ;; Spawn socat processes
        (let [pids (for [port expose-ports]
                     (let [proc (p/process {:out :inherit :err :inherit}
                                           "socat"
                                           (str "TCP-LISTEN:" port ",fork,reuseaddr")
                                           (str "TCP:" container-ip ":" port))]
                       (.pid (:proc proc))))]

          ;; Write state files
          (spit (str (fs/path expose-state-dir "current")) feature)
          (spit (str (fs/path expose-state-dir "pids")) (str/join " " pids))

          (println (str "Exposing sandbox '" feature "' on ports: " (str/join ", " expose-ports))))))))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): implement expose function"

Task 6: Implement unexpose function

Files:

Step 1: Add unexpose function after expose

(defn unexpose
  "Stop exposing sandbox ports"
  [_args]
  (if (fs/exists? (str (fs/path expose-state-dir "current")))
    (let [feature (str/trim (slurp (str (fs/path expose-state-dir "current"))))]
      (kill-existing-expose!)
      (println (str "Stopped exposing sandbox '" feature "'")))
    (println "No sandbox currently exposed")))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): implement unexpose function"

Task 7: Implement exposed function

Files:

Step 1: Add exposed function after unexpose

(defn exposed
  "Show which sandbox is currently exposed"
  [_args]
  (let [current-file (str (fs/path expose-state-dir "current"))
        pids-file    (str (fs/path expose-state-dir "pids"))]
    (if (and (fs/exists? current-file) (fs/exists? pids-file))
      (let [feature (str/trim (slurp current-file))
            pids    (str/split (slurp pids-file) #"\s+")
            alive?  (some (fn [pid]
                            (when-not (str/blank? pid)
                              (zero? (:exit (p/shell {:continue true :out :string}
                                                     "kill" "-0" pid)))))
                          pids)]
        (if alive?
          (println (str "Currently exposed: " feature " (ports: " (str/join ", " expose-ports) ")"))
          (do
            (kill-existing-expose!)
            (println "No sandbox currently exposed (stale state cleaned)"))))
      (println "No sandbox currently exposed"))))

Step 2: Commit

git add scripts/sandbox.clj
git commit -m "feat(sandbox): implement exposed function"

Task 8: Add bb tasks for expose commands

Files:

Step 1: Add tasks before the closing braces

Add after sandbox:clean task (before line 114):

  sandbox:expose
  {:doc      "Expose sandbox ports to host: bb sandbox:expose <feature-name>"
   :requires ([sandbox])
   :task     (sandbox/expose *command-line-args*)}

  sandbox:unexpose
  {:doc      "Stop exposing sandbox ports: bb sandbox:unexpose"
   :requires ([sandbox])
   :task     (sandbox/unexpose *command-line-args*)}

  sandbox:exposed
  {:doc      "Show which sandbox is currently exposed"
   :requires ([sandbox])
   :task     (sandbox/exposed *command-line-args*)}

Step 2: Commit

git add bb.edn
git commit -m "feat(sandbox): add bb tasks for expose/unexpose/exposed"

Task 9: Add .sandbox-expose to .gitignore

Files:

Step 1: Add ignore pattern

Add to .gitignore:

.worktrees/.sandbox-expose/

Step 2: Commit

git add .gitignore
git commit -m "chore: ignore sandbox expose state directory"

Task 10: Manual test

Step 1: Verify commands exist

bb tasks | grep sandbox

Expected: should show all sandbox tasks including expose, unexpose, exposed

Step 2: Test exposed with no sandbox

bb sandbox:exposed

Expected: "No sandbox currently exposed"

Step 3: Test expose error case

bb sandbox:expose nonexistent

Expected: "Error: sandbox 'nonexistent' is not running"

Step 4: Test with running sandbox (if available)

If you have a running sandbox, test the full flow:

bb sandbox:expose <feature-name>
bb sandbox:exposed
curl -s http://localhost:8888 || echo "ERP not responding (expected if not started)"
bb sandbox:unexpose
bb sandbox:exposed