sorcar-cloud — correctness review and live end-to-end testTarget host ksen@34.42.88.157 (Ubuntu 24.04, 32 vCPU GCP VM) · script version reviewed: 924 lines · 4 deployments executed · 4 defects found and fixed · commit 936fcf2f
sorcar-cloud takes the checkout it lives in — including uncommitted edits — pushes it to a
Linux box over SSH, installs KISS Sorcar there, starts code-server (VS Code in the browser) plus the
kiss-web webapp, tunnels both back to the laptop, and opens the browser. Its sibling
sorcar-linux curls a fresh clone from GitHub instead; the whole point of sorcar-cloud is
that the remote runs your working directory.
$HOME, derive the project name (a git worktree maps to the main repo's basename, e.g. kiss)local~/kiss on the remote, then stream the tree there with tar | ssh tar (or rsync for --keep-remote)transport~/.kiss/ (models, tokens, config) minus host-local state; rewrite work_dir; copy ~/.ssh/ without authorized_keysstateapi_keys.env + a systemd EnvironmentFilesecretscode-server + a code shim, give the copy a real git repo, drop a foreign .venv, run the full install.shremotecode-server on 127.0.0.1:$PORT, open the SSH tunnels, open the browser (which activates the extension, which starts kiss-web), then poll the webapplaunch
The script's architecture is sound and unusually well reasoned — the exclusion lists, the
“browser must come before the webapp check” ordering, and the foreign-.venv guard all
address real failure modes. Static analysis was clean (bash -n passes; shellcheck reports
only intentional SC2029 client-side expansions, cosmetic SC2088 tildes inside message strings,
and one SC2015 that is correct as written).
Four real defects were found. One of them was the direct cause of the “Could not request local forwarding / is local port 8080 already in use?” failure that started this thread, and — importantly — it was introduced by an earlier well-intentioned patch in this same session. All four are fixed and verified against the live VM.
bug fixed
The script backgrounded the tunnel with ssh -f and then tried to recover its PID by pattern-matching
the process table:
TUNNEL_PID="$(pgrep -f "ssh -f -N.*-L ${LOCAL_PORT}:127.0.0.1:${PORT}.*${TARGET}" | head -1 || true)"
The pattern assumed that when ssh forks for -f it rewrites its own argv into
-f -N order. I tested that assumption directly:
$ ssh -N -f -o ExitOnForwardFailure=yes -L 19099:127.0.0.1:8080 ksen@34.42.88.157
$ ps -Ao pid,command | grep 19099
16570 ssh -N -f -o ExitOnForwardFailure=yes -L 19099:127.0.0.1:8080 ksen@34.42.88.157
$ pgrep -f "ssh -f -N.*19099..." → NO MATCH
$ pgrep -f "ssh -N -f.*19099..." → 16570
ssh does not reorder its argv. The flags stay exactly as typed, so the pattern could never match,
TUNNEL_PID was always empty, the if [[ -n "$TUNNEL_PID" ]] block at the end was skipped, the
Ctrl+C trap was never installed — and the script returned immediately after printing its banner, leaving a
detached ssh -f holding local port 8080 forever. The next run then died with
ExitOnForwardFailure. (The stale tunnel seen earlier looked like -f -N simply because an
older version of the script had invoked it in that order.)
TUNNEL_ARGS=(-N -n -o ExitOnForwardFailure=yes -o ServerAliveInterval=30
-L "${LOCAL_PORT}:127.0.0.1:${PORT}")
...
ssh "${TUNNEL_ARGS[@]}" "$TARGET" &
TUNNEL_PID=$!
for _ in $(seq 1 10); do
kill -0 "$TUNNEL_PID" 2>/dev/null || break
sleep 0.5
done
kill -0 "$TUNNEL_PID" 2>/dev/null \
|| die "Could not open the SSH tunnels (is local port $LOCAL_PORT already in use?)."
$! is exact — no pattern can mismatch, and two tunnels to the same host can't be confused.-n points ssh's stdin at /dev/null so the background job can't swallow the script's stdin.ExitOnForwardFailure contract: still alive after the grace period ⇒ every forward was granted. The loop exits the instant ssh dies, so failures are still reported promptly.
My first version confirmed readiness with nc -z 127.0.0.1 $LOCAL_PORT. Adversarial testing killed
that idea: with a foreign listener already on 8080, nc succeeds on the first iteration, the
loop breaks instantly, and kill -0 still sees our ssh in the sub-second before
ExitOnForwardFailure terminates it — a false success.
# nc-based check, busy port 8080
[INFO] Tunnels are up (ssh pid 18192) ← wrong
./t.sh: line 22: 18192 Terminated: 15 ssh ...
PORT STILL BOUND (bad)
# final check, busy port 8080
[ERR] Could not open the SSH tunnels (is local port 8080 already in use?). exit=1 ← right
A port probe answers “someone is listening”, never “my forward is listening”. The probe was removed.
install.sh could pull GitHub's main over the code you just shippedbug fixed
Step 7g runs the full install.sh on the remote, whose update_repo() does
git stash push --include-untracked → git fetch origin → git pull --ff-only,
and on divergence git reset --hard '@{upstream}'.
When the laptop checkout is a git worktree, step 7e replaces the dangling .git pointer with a
fresh local repo that has no origin, so the fetch fails harmlessly (that is the
WARNING: git fetch failed (offline?) line in the log). But when sorcar-cloud is run from an
ordinary clone, .git — and therefore origin and the upstream tracking branch — travels
verbatim. The remote then quietly fast-forwards to origin/main, contradicting the script's headline
promise to deploy this exact working directory; and if the auto-stash of your uncommitted edits fails to
re-apply, those edits are stranded in a stash on a machine you don't think of as a checkout.
Fix — a new opt-out in install.sh, matching the existing KISS_SKIP_LAUNCH idiom, which sorcar-cloud now sets:
# install.sh, update_repo()
if [ -n "${KISS_SKIP_UPDATE:-}" ]; then
echo " KISS_SKIP_UPDATE set — installing this checkout as-is, no pull."
return 0
fi
# sorcar-cloud, step 7g
KISS_CODE_CLI=code-server KISS_SKIP_LAUNCH=1 KISS_SKIP_UPDATE=1 \
bash "$WORKSPACE/install.sh"
Confirmed in the live log: >>> Updating kiss_ai repository... is now followed by KISS_SKIP_UPDATE set — installing this checkout as-is, no pull.
~/.kiss mirror could delete the remote's API keysbug fixed
Section 5a mirrors ~/.kiss/ with --delete so retired local overrides can't linger on the
remote. But api_keys.env and api_keys.systemd.env exist only on the remote — step 6
creates them there. Since the mirror runs before step 6, every run deleted the remote's credential files and
step 6 recreated them… unless the laptop has no rc file containing API_KEY, in which case step 6
is skipped entirely and the remote is left with no keys, a dead kiss-web unit
(EnvironmentFile gone) and an extension that prompts for keys. Both names are now excluded from the
mirror, so the copy can only ever add credentials, never remove them.
wasteful fixed
Agent tasks run in <repo>/.kiss-worktrees/kiss_wt-*/. Each is a full second checkout
(node_modules included) whose .git is a one-line file pointing at an absolute laptop path
that does not exist on the remote. Copying them multiplies the transfer for zero benefit and drags dead git pointers
into the snapshot commit step 7e creates. .kiss-worktrees is now excluded in both transports
(the tar stream and the rsync path), next to the existing .venv filter.
Four deployments were run against the VM, covering both transports, both tunnel outcomes, and a real agent task.
| Run | Mode | What it proved | Result |
|---|---|---|---|
| 1 | full wipe + tunnel | Webapp port auto-probe (8787 busy → 18787), worktree → project name kiss,
208 MB copy, install.sh to completion, kiss-web live through the tunnel,
Tunnels are up (ssh pid …) + hold loop |
pass |
| 2 | full wipe, SORCAR_NO_TUNNEL=1 |
Final script incl. KISS_SKIP_UPDATE; exit 0; correct manual-tunnel hint printed |
pass |
| 3 | --keep-remote |
rsync delta path: 1462 files scanned, 5 transferred; .venv kept
(“native and complete on this host”); .git left alone;
IDE port auto-probe fired for real — Local port 8080 is busy — tunneling the remote IDE
to local port 18080 instead |
pass |
| 4 | full wipe + tunnel, killed with SIGTERM | Trap teardown: Closing the SSH tunnels..., after which nothing listens on local 8080 and
no ssh tunnel process remains — the leak that caused the original bug report |
pass |
The tunneled IDE was opened in a browser, which redirected to
http://localhost:8080/?folder=/home/ksen/kiss — confirming step 7h's coder.json
query.folder rewrite. The workbench rendered the pushed tree (including a .venv that exists
only because the remote built it from uv.lock), the KISS Sorcar panel activated, and the status bar
advertised Forwarded Ports: 8787, … — i.e. the extension had started kiss-web.
A 257-byte task file was created on the VM, opened in the editor, selected with ⌘A
(Ln 2, Col 1 (257 selected)), and submitted with ⌘E
(kissSorcar.runSelection). The panel reported Done (7s) · Tokens 18,076 · Cost $0.0668 ·
Steps 2. The VM's own task database tells the rest:
-- ~/.kiss/sorcar.db, newest task_history row
task : Run `uname -a` and `hostname` and `python3 -c "print(2**16)"` …
result : <p>ksen-vm-32.c.r2eg-441800.internal</p> <p>65536</p>
model : claude-opus-5 work_dir : /home/ksen/kiss
version : 2026.7.32 tokens : 18076 cost : 0.066848
is_worktree : 1 auto_commit_mode : 1
-- events (event_json)
tool_call Bash: mkdir -p tmp && { uname -a; hostname; python3 -c "print(2**16)"; } | tee tmp/remote_proof.txt
system_output Linux ksen-vm-32.c.r2eg-441800.internal 6.17.0-1021-gcp #24~24.04.1-Ubuntu … x86_64 GNU/Linux
system_output ksen-vm-32.c.r2eg-441800.internal / 65536
tool_call finish(success=True)
The GCP kernel string and the VM's internal hostname could not have come from the laptop, so the task genuinely ran
on the remote machine — and the successful claude-opus-5 call independently proves the whole
API-key pipeline (~/.zshrc → api_keys.env → code-server → agent) works there.
One thing that looks like a failure but isn't: tmp/remote_proof.txt is absent from ~/kiss.
The task ran with worktree isolation (is_worktree: 1) inside
.kiss-worktrees/kiss_wt-*/, and .gitignore line 29 is tmp/ — an ignored file is
never committed, so it was discarded with the worktree. Normal KISS behaviour, not a deployment problem.
./tmp/*.log scratch files appeared in the
remote Explorer. That is the documented contract, but worth knowing before pushing a directory with large local
junk in it.~/.ssh/config travels unfiltered. Harmless here (a single GitHub stanza), but a
macOS-only option such as UseKeychain or an IdentityAgent path under
~/Library would make the remote's ssh complain or silently lose the agent.pkill -f 'code-server' in the wipe step is broad — it stops every
code-server on the host, not just one serving the doomed directory. Deliberate (a running server would keep
rewriting the tree), but it does affect unrelated workspaces on a shared box..venv and
node_modules; a full wipe-and-redeploy discards and rebuilds them (--keep-remote keeps them).| File | Change | Why |
|---|---|---|
sorcar-cloud §8/§11 | ssh -N -n … & + $!, alive-after-grace readiness check, unconditional trap | Defect 1 — orphaned tunnel held local $PORT |
sorcar-cloud §7g | pass KISS_SKIP_UPDATE=1 | Defect 2 — pull overwrote the shipped tree |
install.sh update_repo() | honour KISS_SKIP_UPDATE | Defect 2 |
sorcar-cloud §5a | --exclude='api_keys.env', --exclude='api_keys.systemd.env' | Defect 3 — --delete erased remote credentials |
sorcar-cloud copy_tree() | --exclude='.kiss-worktrees' in both transports | Defect 4 — shipped sibling worktrees |
bash -n passes on both files; shellcheck shows no new findings. Committed as 936fcf2f.