[BUG] Cowork sandbox fails at sdk_install on Windows — VM guest crashes with "connection forcibly closed" (regression SDK 2.1.181 → 2.1.202)

Status Fixed / completed
Reported on v2.1.202
Maintainer reply None cached
Activity 6 comments · opened Jul 9, 2026 · closed Jul 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

  • App 1.19367.0, SDK 2.1.202, Windows 11 Pro 10.0.26200
  • Last working: SDK 2.1.181, June 21 2026
  • Same VM bundle (6d1538ba...) both times — bundle is not the issue
  • VM boots and connects, but sdk_install fails 3 seconds in with An existing connection was forcibly closed by the remote host
  • Linux claude binary (249.7 MB ELF) exists and is valid; crash appears to be inside the VM guest when running/loading it
  • Restarting vmcompute service clears the zombie-VM compounding issue but the root crash repeats
  • Logs: %APPDATA%\Claude\logs\cowork_vm_node.log

cowork_vm_node.log

What Should Happen?

Should not give error

Error Messages/Logs

Failed to start Claude's workspace
failed to send request: failed to write message: An existing connection was forcibly closed by the remote host.
Restarting Claude or your computer sometimes resolves this. If it persists, you can reinstall the workspace or share your debug logs to help us improve.

Steps to Reproduce

  1. Reinstall "workspace"
  2. Start claude desktop
  3. go to cowork and try to do something that requires the sandbox

Claude Model

Sonnet (default)

Is this a regression?

Yes, this worked in a previous version

Last Working Version

SDK 2.1.181 (June 21, 2026)

Claude Code Version

This is a claude desktop issue — App 1.19367.0, bundled SDK 2.1.202

Platform

Claude Pro/Max

Operating System

Windows

Terminal/Shell

PowerShell

Additional Information

_No response_

View original on GitHub ↗

6 Comments

andrey-savov · 1 month ago

Additional technical detail

Exact failure sequence (cowork_vm_node.log)

2026-07-09 07:55:07 [info] [VM:steps] vm_boot completed (186ms)
2026-07-09 07:55:08 [info] [VM:steps] add_plan9_shares completed (32ms)
2026-07-09 07:55:11 [info] [VM] Network status: CONNECTED
2026-07-09 07:55:11 [info] [VM:steps] sdk_install started
2026-07-09 07:55:11 [info] [postConnect] Installing SDK: subpath=c/Users/savov/AppData/Roaming/Claude/claude-code-vm, version=2.1.202
2026-07-09 07:55:14 [error] [VM:start] Startup failed: Error: failed to send request: failed to write message: An existing connection was forcibly closed by the remote host.

The VM guest closes the connection ~3 seconds after receiving the sdk_install command. Everything before that step succeeds cleanly.

Regression confirmed — same VM bundle, different SDK

| Date | SDK Version | Bundle (6d1538ba...) | Result |
|------|------------|----------------------|--------|
| June 21, 2026 | 2.1.181 | unchanged | ✅ vmStatus=ready |
| July 9, 2026 | 2.1.202 | unchanged | ❌ crash at sdk_install |

  • rootfs.vhdx, vmlinuz, initrd are identical between sessions — the VM image is not the cause
  • The Linux claude binary at claude-code-vm/2.1.202/claude is a valid 249.7 MB ELF (magic bytes verified)
  • SDK version history in logs: 2.1.160 → 2.1.181 → 2.1.197 → 2.1.202 (landed July 8)
  • Crash happens ~3 seconds after the VM guest receives the install command via Plan 9 mount

Secondary bug: zombie VM blocks retries

After the first crash, vmcompute (Windows Host Compute Service) keeps the crashed VM instance registered. All subsequent boot attempts reuse the same dead instance ID and time out after 60 seconds:

2026-07-09 07:25:46 [error] [VM:start] Connection timeout, last completed step: load_swift_api
2026-07-09 07:25:46 [error] [VM:start] Startup failed: Error: VM connection timeout after 60 seconds

Workaround: Restart-Service vmcompute (elevated) clears the zombie and allows a fresh boot — though the root sdk_install crash repeats.

System info

  • App: Claude_1.19367.0.0_x64, SDK: 2.1.202, OS: Windows 11 Pro 10.0.26200
  • Hyper-V services running (vmcompute, HvHost, vmms), HypervisorPresent=True
  • 31.4 GB RAM; Windows Defender only; no Windows Updates since June 10
  • Auto-reinstall and manual bundle reinstall both attempted — same result
andrey-savov · 1 month ago

Update: SDK 2.1.205 also affected (app 1.20186.0.0)

Just upgraded to the latest Claude Desktop release (app 1.20186.0.0) — same crash, same failure point:

2026-07-09 20:32:54 [info] [VM:steps] sdk_install started
2026-07-09 20:32:54 [info] [postConnect] Installing SDK: subpath=c/Users/savov/AppData/Roaming/Claude/claude-code-vm, version=2.1.205
2026-07-09 20:32:58 [error] [VM:start] Startup failed: Error: failed to send request: failed to write message: An existing connection was forcibly closed by the remote host.

Updated regression table:

| SDK Version | App Version | Result |
|------------|-------------|--------|
| 2.1.181 | — | ✅ Working (last seen June 21) |
| 2.1.202 | 1.19367.0.0 | ❌ Crashes at sdk_install |
| 2.1.205 | 1.20186.0.0 | ❌ Crashes at sdk_install |

The fix has not shipped yet as of app 1.20186.0.0.

andrey-savov · 1 month ago

Deep dive: crash pathway identified via rootfs inspection

Mounted and inspected rootfs.vhdx directly to trace the actual failure mechanism.

Architecture (corrected)

The file sharing is a two-layer stack:

  • Windows/HCS layer: 9P (Plan 9) protocol — cowork-svc.exe calls HCS APIs with plan9ShareSettings / NetworkPlan9ShareNames to expose the Windows filesystem to the VM at the hypervisor level
  • Linux guest layer: FUSE — sdk-daemon is itself the FUSE server, bridging the 9P share into a FUSE mount inside the VM (likely /c/). Confirmed via strings in the binary: *fuse.Attr, *fuse.LkIn, mountFd, OnUnmount, tmpshared

sdk-daemon is a statically linked Go binary (15.9 MB) that serves two roles simultaneously: vsock RPC bridge (receives commands from Windows) and FUSE filesystem server (exposes the Windows file tree inside the VM).

Crash pathway

1. VM boots → coworkd.service starts sdk-daemon (old/working version)
2. sdk-daemon initializes FUSE mount + starts vsock listener
3. Windows connects via vsock → sdk_install command sent:
   "install subpath=c/Users/.../claude-code-vm, version=2.1.202"
4. sdk-daemon copies/installs new binary to /usr/local/bin/sdk-daemon
5. sdk-daemon replaces itself with 2.1.202 binary
6. New 2.1.202 sdk-daemon crashes on startup
   → vsock connection forcibly closed
   → Windows sees: "An existing connection was forcibly closed by the remote host"

The crash in step 6 is the root cause. Whether it's a panic in the FUSE initialization, vsock listener setup, or something else inside the 2.1.202 binary is not visible from the guest side (no crash logs accessible; /sessions is empty on disk).

Key findings from rootfs

  • /usr/local/bin/sdk-daemon — 15.9 MB statically linked ELF (Go), last written Jul 9 07:20 (from an earlier failed install of 2.1.202)
  • /usr/local/bin/sandbox-helper — 2.1 MB statically linked ELF (Go)
  • /opt/cowork/cli-wrapper and /opt/cowork/claude-bin/claude — 2.3 MB ELF wrappers, created at Jul 9 07:55 during sdk_install
  • /sessions/ — empty; no guest-side crash logs written to disk
  • FUSE stack confirmed present: libfuse.so.2, libfuse3.so.3, fusermount3, mount.fuse3
  • 9P kernel modules present: 9pnet.ko, 9pnet_virtio.ko, 9p.ko (kernel 6.8.0-124-generic)

What would help diagnose further

Guest-side crash logs from sdk-daemon 2.1.202 startup — specifically whatever the Go runtime prints on panic before the process exits. These are currently lost because the vsock closes before they can be transmitted and nothing writes them to disk. If the coworkd.service logged to a file (e.g. /var/log/coworkd.log) or if the guest serial console output was captured, the panic message would be visible.

andrey-savov · 1 month ago

Deep Binary Analysis — Root Cause Confirmed

After reverse-engineering the sdk-daemon binary directly from rootfs.vhdx, I can pinpoint the exact crash mechanism. This is a Go runtime initialization ordering bug in the binary itself — not a kernel, VM, CPU feature, or filesystem issue.

---

Binary Facts

| Field | Value |
|---|---|
| Path inside rootfs | /usr/local/bin/sdk-daemon |
| Module | coworkd (subpkgs: coworkd/rpc, coworkd/internal/proxy) |
| Go version | go1.24.13 — this is NOT a public release; max public is ~1.24.4 |
| Binary | 15.9 MB ELF64, statically linked, fully stripped |
| BuildID | 123d08c38785ca39ba0303ccca9359e5999d2869 |

go1.24.13 does not exist in the public Go release history. This binary was built with an internal Anthropic Go fork/patch, which is almost certainly where the bug was introduced.

---

Crash Reproduction

$ strace /tmp/sdk-daemon 2>&1 | tail -5
arch_prctl(ARCH_SET_FS, 0x13344f0)  = 0
sched_getaffinity(0, 8192, [...])   = 8
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x0} ---

Only two syscalls execute before the crash — the process never gets a chance to open any file, socket, or vsock connection. The "forcibly closed" error upstream is purely a consequence of the VM process dying before it sends anything.

---

Crash Location

VA 0x4189f8:  test %al, (%rsi)    ← SIGSEGV here; rsi = 0x0

This is inside the Go allocator's size-class dispatch (function at 0x418920). The sequence leading to it:

; rsi = m.mcache (from offset 0xd0 in the M struct)
0x41894b: mov  0xd0(%rdx), %rsi
0x418952: test %rsi, %rsi
0x418955: je   0x41895d           ; mcache is NULL → try global fallback
0x41895d: mov  0xf3cae4(%rip), %rsi   ; load global fallback at VA 0x1355448
                                       ; 0x1355448 is in BSS → zero-initialized → NULL
0x4189f8: test %al, (%rsi)        ; CRASH: rsi still 0

Both m.mcache and the global fallback are NULL because mallocinit() has never been called at this point.

---

Root Cause: Initialization Order Bug

The Go runtime entry (rt0_go at 0x479660) follows this sequence:

0x479777: call 0x47e140    ; check()
0x47977c: ...              ; args()
0x479792: call 0x47df20    ; ← should be osinit()
0x479797: call 0x47e060    ; schedinit() — calls mallocinit() — NEVER REACHED
0x4797a4: call 0x47e0c0    ; newproc() — NEVER REACHED

The stub at 0x47df20 is supposed to be osinit. But it routes to a different function:

; 0x47df20 — "osinit" ABI stub
0x47df20: xorps %xmm15, %xmm15
0x47df24: mov   %fs:0xfffffffffffffff8, %r14
0x47df2d: jmp   0x43b420    ← NOT standard osinit!

Function 0x43b420 does this:

0x43b42a: call 0x43ac20    ; getproccount() → sched_getaffinity → returns CPU count
0x43b42f: mov  %eax, 0x1355304   ; store ncpu
0x43b435: call 0x43b300    ; ← CRASH: runs package init tasks
0x43b43a: ...              ; never reached

0x43b300 is a package init task runner. It calls init functions via a dispatch stub at 0x47bb600x43bfe0, which calls runtime.newobject (VA 0x415e80) → mallocgc (VA 0x471900) → the allocator at 0x418920. The allocator needs m.mcache, which mallocinit() sets up — but mallocinit() is inside schedinit() at 0x442280, which hasn't been called yet.

---

Complete Crash Call Chain (from core dump stack)

[0] 0x4189f8  — allocator size-class dispatch (crash point)
[1] 0x4719b9  — mallocgc dispatch (0x471900)
[2] 0x415ea8  — runtime.newobject (0x415e80)
[3] 0x43c045  — allocates 24-byte struct (type VA 0xb92240, kind=struct, no pointers)
[4] 0x43b405  — init task runner (0x43b300): calls 0x47bb60 → 0x43c040
[5] 0x43b43a  — 0x43b420: calls 0x43b300 at 0x43b435
[6] 0x479797  — rt0_go: calls "osinit" stub 0x47df20 at 0x479792
[7] 0x47cde0  — ELF entry point → jmp rt0_go

---

What Changed

In standard Go, osinit() only calls getproccount() + getHugePageSize() + osArchInit() — none of which allocate heap memory. Package inits run much later, after schedinit()/mallocinit().

In this binary (go1.24.13), the function placed in the osinit slot (0x43b420) has been extended to also run package-level init tasks (0x43b300) before schedinit() has been called. This means package inits that call new(T) or any heap allocation crash immediately.

This is almost certainly a regression introduced in Anthropic's custom Go build — either a linker change that miscategorized inits, or a runtime patch that moved the init runner too early in rt0_go.

---

What is NOT the Cause

  • Not AVX2: CPU supports it (confirmed in /proc/cpuinfo), crash is SEGV_MAPERR not SIGILL
  • Not the kernel version: The Go allocator crash happens entirely in user space before any problematic syscall
  • Not FUSE/vsock: The process dies before reaching any networking or filesystem code
  • Not VM/HCS configuration: Crash reproduces identically running the binary directly in WSL2

---

Fix Required

The sdk-daemon binary needs to be rebuilt with the correct initialization order. Package-level init functions that allocate heap memory must not run before schedinit()/mallocinit() in rt0_go. The smol-bin updater that pushed sdk-daemon 2.1.205 delivered a binary that immediately segfaults on startup.

andrey-savov · 1 month ago

Workaround Found — Binary Patch + Persistence Mechanism (Windows)

Following up on the root-cause analysis above (premature package-init call before mallocinit()/schedinit() in rt0_go), I built and verified a working patch. It fixes the crash, but cowork still cannot fully start due to a second, unrelated bug (filed separately as #77005 — the guest hangs mounting one specific Plan9 share). Documenting the sdk-daemon fix here since it's a complete, verified solution to this issue's root cause, in case it's useful for the eventual real fix or for anyone hitting only this crash.

The fix

The crash call site is a single premature call instruction. NOP-ing it out removes the premature package-init call; init functions then run normally later via runtime.main()doInit(), after the scheduler and allocator are up.

File:   /usr/local/bin/sdk-daemon  (inside rootfs.vhdx)
Offset: 0x3b435 (file offset == VA, since ELF is loaded at 0x400000 with 1:1 file/VA mapping)
Before: e8 c6 fe ff ff     (call 0x43b300 — init task runner, runs before mallocinit)
After:  90 90 90 90 90     (5x NOP)

SHA256 before: 2b5c4af9f4d2ebc370dedbd1d6ca621bc9087670a0857a8cf351660622d0e543
SHA256 after:  b2c33df261e737419e220f970d49fb6f34427cebdb3099815fcf6ab1b0ac24f0

Verified: patched binary boots cleanly all the way through networking, RPC handshake, session-user recovery (24/24 recovered), and 3 of 4 attempted Plan9 share mounts — a dramatic improvement over the immediate SIGSEGV the original binary hits on syscall #3.

Why a simple rootfs patch doesn't stick

coworkd's own self-updater compares the running binary's SHA256 against the copy on the smol-bin FAT volume (labeled SDK_DAEMON, mounted from smol-bin.x64.vhdx in the app package) on every start, and overwrites+restarts itself if they differ. Since smol-bin.x64.vhdx is re-copied from the installed app package into the VM bundle before every VM boot, patching only rootfs.vhdx gets silently reverted within seconds of the first start — the updater pulls the broken binary right back in.

Full persistence requires patching three places, in order:

  1. smol-bin.x64.vhdx inside the installed app package (...\WindowsApps\Claude_<ver>\app\resources\smol-bin.x64.vhdx) — the ultimate source of truth the updater trusts. Requires taking ownership (takeown/icacls) since it's in a protected AppX/MSIX package directory.
  2. rootfs.vhdx's /usr/local/bin/sdk-daemon — so the first boot after this fix is already patched (otherwise the first start still crashes before the updater has a chance to sync from the now-patched smol-bin).
  3. coworkd.service — add ExecStartPre=/usr/local/bin/sdk-patch, a small idempotent Python script installed at /usr/local/bin/sdk-patch that re-applies the NOP patch to /usr/local/bin/sdk-daemon before every service (re)start. This guards against any future smol-bin update reintroducing the broken binary (e.g. after an app auto-update ships a new sdk-daemon) triggering the crash loop again before the next patch cycle.

sdk-patch (installed at /usr/local/bin/sdk-patch, mode 755):

#!/usr/bin/env python3
"""
sdk-patch: Idempotent NOP-patcher for the coworkd sdk-daemon binary.
Guards against the premature package-init crash before mallocinit()/schedinit().
Run as ExecStartPre in coworkd.service. Always exits 0 (never blocks startup).
"""
import hashlib, sys

BINARY  = '/usr/local/bin/sdk-daemon'
BROKEN  = '2b5c4af9f4d2ebc370dedbd1d6ca621bc9087670a0857a8cf351660622d0e543'
OFFSET  = 0x3b435
ORIG    = bytes([0xe8, 0xc6, 0xfe, 0xff, 0xff])
NOP5    = bytes([0x90, 0x90, 0x90, 0x90, 0x90])

try:
    with open(BINARY, 'rb') as f:
        data = f.read()

    patch_site = data[OFFSET:OFFSET+5]
    if patch_site == NOP5:
        sys.exit(0)                        # already patched

    if hashlib.sha256(data).hexdigest() != BROKEN:
        sys.exit(0)                        # unknown version - let it run

    if patch_site != ORIG:
        sys.exit(0)                        # unexpected bytes - bail safely

    with open(BINARY, 'r+b') as f:
        f.seek(OFFSET)
        f.write(NOP5)

except Exception:
    pass                                    # never block service startup

sys.exit(0)

coworkd.service diff:

 [Service]
 Type=simple
+ExecStartPre=/usr/local/bin/sdk-patch
 ExecStart=/usr/local/bin/sdk-daemon
 Restart=always

All three edits back up the file they modify (.bak suffix) before writing, and every patch step verifies the SHA256 of the input before touching it — refuses to run if the binary doesn't match the known-broken hash exactly.

Verification

I rebuilt rootfs.vhdx from scratch from the app's own pristine rootfs.vhdx.zst snapshot (bundled alongside the live one, presumably for the "reinstall workspace" recovery path) and re-applied only the sdk-daemon binary swap — confirming the fix isn't dependent on any of the incidental state that had accumulated in my working rootfs over months of use. Same result both times: clean full boot, past sdk_install.

What's still broken

With this crash fixed, cowork now reaches Plan9 share mounting and hangs indefinitely on one specific share, causing the eventual "connection forcibly closed" / 60s timeout. That's a separate bug, unrelated to this one — filed as #77005 since the mechanism (guest fully up, most shares mount fine, one hangs forever) doesn't match any existing report I could find, including the superficially similar #60875/#66348 (those are guest-never-calls-back-at-all, a different failure mode).

andrey-savov · 1 month ago

Update: crash appears fixed upstream as of App 1.24012.9.0

Following up on the binary-level root cause and workaround posted above — I hit a timeout again today after Claude Desktop auto-updated (1.20186.1.0 → 1.24012.9.0), so I re-checked whether the same crash had returned.

It hasn't. The sdk-daemon binary shipped in this version's smol-bin.x64.vhdx is a different build entirely:

SHA256 (this issue's broken build, App 1.20186.1.0): 2b5c4af9f4d2ebc370dedbd1d6ca621bc9087670a0857a8cf351660622d0e543
SHA256 (my NOP-patched build):                       b2c33df261e737419e220f970d49fb6f34427cebdb3099815fcf6ab1b0ac24f0
SHA256 (new build, App 1.24012.9.0):                 184b45670c4d458d4280d6c5b496c13e9eb2779b23a67c10e277ead2c0040c1c

Different hash, different size, and — most importantly — it boots cleanly with no patch applied. So whatever caused the premature init_runner call before mallocinit()/schedinit() in the old build appears to have been fixed in a subsequent sdk-daemon rebuild.

The timeout I hit today was the unrelated Plan9/network-drive hang (#77005), not this crash — confirmed by disconnecting my mapped network drives, which let cowork start successfully.

Given this, the NOP-patch workaround above is no longer needed on current app versions — closing this one out. Thanks for the platform, and see #77005 for the remaining Plan9-share issue if you have mapped network drives.