Skip to content

Commit 955fe8b

Browse files
committed
fix: store separate socket proxies for socket based tooling
1 parent f3d74b8 commit 955fe8b

14 files changed

Lines changed: 572 additions & 31 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased](https://github.com/zekker6/devsandbox/compare/v0.17.3...HEAD)
66

7+
### Fixed
8+
9+
- **A second session for the same project no longer breaks a running session's notifications, Docker access, and kitty remote control.** The portal, Docker, and kitty proxies each created their unix socket at a path keyed only on the project (`<sandbox home>/.dbus-proxy/bus`, `<sandbox home>/docker.sock`, `<sandbox home>/.kitty.sock`), but sandbox home is shared by every session for that project. Starting a second session unlinked the live session's socket and re-created it, and that session's exit deleted the path outright - leaving the first session with `DBUS_SESSION_BUS_ADDRESS`, `DOCKER_HOST`, and `KITTY_LISTEN_ON` pointing at a path that no longer existed. `notify-send` failed with `Could not connect: No such file or directory` for the rest of the session, with no way to recover short of restarting it. Each session's sockets now live in a directory private to the owning process (`<sandbox home>/.run/<pid>/`), so concurrent sessions cannot disturb each other; directories left by sessions that are gone are reclaimed on the next start. `DBUS_SESSION_BUS_ADDRESS` is unchanged; `DOCKER_HOST` and `KITTY_LISTEN_ON` now point at `$HOME/.run/<pid>/docker.sock` and `$HOME/.run/<pid>/kitty.sock` inside the sandbox.
10+
- **A socket path too long for the kernel is now reported as such.** The proxy socket paths above sit under an already-long sandbox home, and `bind(2)` rejects anything past 107 bytes (103 on macOS) with a bare `invalid argument` - which the portal surfaced only as an opaque "proxy socket not created" timeout. The portal, Docker, and kitty proxies now fail with the path, its length, the limit, and the remedy (a shorter project directory name).
11+
712
## [v0.17.3](https://github.com/zekker6/devsandbox/releases/tag/v0.17.3) - 2026-06-24
813

914
### Added

docs/tools.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ socket = "/path/to/docker.sock"
520520

521521
### How It Works
522522

523-
1. A Unix socket proxy is created at `$HOME/docker.sock` inside the sandbox
523+
1. A Unix socket proxy is created at `$HOME/.run/<pid>/docker.sock` inside the sandbox, where `<pid>` is the devsandbox process that owns the session. Sandbox home is shared by every session for the project, so the socket is kept per-session to stop a second session from unlinking a live one's socket.
524524
2. The `DOCKER_HOST` environment variable is set to point to this socket
525525
3. All requests are filtered before being forwarded to the host Docker socket
526526
4. Write operations are blocked with an HTTP 403 error
@@ -609,14 +609,14 @@ extra_capabilities = ["list_owned"] # additive only; launch_* entries are rej
609609

610610
| Resource | Mode | Purpose |
611611
|----------|------|---------|
612-
| Proxy socket (`$HOME/.kitty.sock`) | read-write (proxy is local to the sandbox home) | kitty remote-control via the filtering proxy |
612+
| Proxy socket (`$HOME/.run/<pid>/kitty.sock`) | read-write (proxy is local to the sandbox home) | kitty remote-control via the filtering proxy |
613613
| `kitty` binary | read-only | CLI for `kitty @ launch`, `kitty @ ls`, etc. |
614614

615615
The host's real kitty socket is **not** bind-mounted into the sandbox.
616616

617617
### Environment Variables
618618

619-
- `KITTY_LISTEN_ON` - rewritten to `unix:$HOME/.kitty.sock` inside the sandbox. Host value is never exposed.
619+
- `KITTY_LISTEN_ON` - rewritten to `unix:$HOME/.run/<pid>/kitty.sock` inside the sandbox, where `<pid>` is the devsandbox process owning the session. Host value is never exposed.
620620
- `KITTY_WINDOW_ID`, `KITTY_PID` - passed through from host (read-only signals about the host pane).
621621

622622
### Limitations
@@ -698,7 +698,7 @@ notifications = false
698698
4. `DBUS_SESSION_BUS_ADDRESS` inside the sandbox points to the proxy socket
699699
5. A `.flatpak-info` file is created so `xdg-desktop-portal` recognizes the sandbox as a valid Flatpak-like application
700700

701-
The proxy is started before the sandbox launches and stopped when the sandbox exits.
701+
The proxy is started before the sandbox launches and stopped when the sandbox exits. Each session runs its own proxy with its own socket on the host, so starting or exiting a second session for the same project leaves a running session's notifications working.
702702

703703
### Checking Portal Status
704704

internal/isolator/docker_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,12 +1177,12 @@ func TestGetToolBindings_DockerHostRemapped(t *testing.T) {
11771177
t.Fatal("DOCKER_HOST environment variable not found in tool bindings")
11781178
}
11791179

1180-
// DOCKER_HOST should point to /home/sandboxuser, not host homeDir
1180+
// DOCKER_HOST should point under /home/sandboxuser, not host homeDir
11811181
if strings.Contains(dockerHost, "/home/testuser") {
11821182
t.Errorf("DOCKER_HOST should not contain host homeDir, got: %s", dockerHost)
11831183
}
1184-
if !strings.Contains(dockerHost, "/home/sandboxuser/docker.sock") {
1185-
t.Errorf("DOCKER_HOST should point to /home/sandboxuser/docker.sock, got: %s", dockerHost)
1184+
if !strings.HasPrefix(dockerHost, "unix:///home/sandboxuser/") || !strings.HasSuffix(dockerHost, "/docker.sock") {
1185+
t.Errorf("DOCKER_HOST should point to a docker.sock under /home/sandboxuser, got: %s", dockerHost)
11861186
}
11871187
}
11881188

internal/sandbox/tools/active.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ func (r *ActiveToolsRunner) start(ctx context.Context) (bool, error) {
4343
home := r.cfg.HomeDir
4444
sandboxHome := r.cfg.SandboxHome
4545

46+
// Reclaim socket dirs from runs that are gone. Must happen before any tool
47+
// creates its sockets, so that a PID reused from a crashed run starts clean.
48+
if _, err := cleanupStaleRunDirs(sandboxHome); err != nil && r.logger != nil {
49+
r.logger.LogErrorf("tools", "clean stale run dirs: %v", err)
50+
}
51+
4652
// Find and start active tools
4753
for _, tool := range Available(home) {
4854
at, ok := tool.(ActiveTool)

internal/sandbox/tools/docker.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tools
22

33
import (
44
"context"
5+
"fmt"
56
"os"
67
"path/filepath"
78
"runtime"
@@ -93,9 +94,11 @@ func (d *Docker) Available(homeDir string) bool {
9394
}
9495

9596
// socketPath returns the path where the proxy socket will be created.
96-
// It's placed in sandboxHome so it's visible inside the sandbox.
97+
// It's placed in sandboxHome so it's visible inside the sandbox, under this
98+
// process's run directory so that a concurrent session for the same project
99+
// cannot unlink a live socket.
97100
func (d *Docker) socketPath(sandboxHome string) string {
98-
return filepath.Join(sandboxHome, dockerSocketName)
101+
return filepath.Join(runDir(sandboxHome), dockerSocketName)
99102
}
100103

101104
// Configure implements ToolWithConfig.
@@ -131,10 +134,10 @@ func (d *Docker) Environment(homeDir, sandboxHome string) []EnvVar {
131134
return nil
132135
}
133136

134-
// The socket is created at sandboxHome/docker.sock on the host,
135-
// but sandboxHome is mounted at $HOME inside the sandbox.
136-
// So we return $HOME/docker.sock as the path visible inside the sandbox.
137-
sandboxVisiblePath := filepath.Join(homeDir, dockerSocketName)
137+
// The socket is created under sandboxHome on the host, but sandboxHome is
138+
// mounted at $HOME inside the sandbox, so the same relative path under
139+
// homeDir is what the sandbox sees.
140+
sandboxVisiblePath := filepath.Join(runDir(homeDir), dockerSocketName)
138141
return []EnvVar{
139142
{Name: "DOCKER_HOST", Value: "unix://" + sandboxVisiblePath},
140143
}
@@ -153,7 +156,15 @@ func (d *Docker) Start(ctx context.Context, homeDir, sandboxHome string) error {
153156
notice.Warn("Docker socket proxy enabled. The sandbox can access ALL existing Docker containers on this host.")
154157
notice.Warn("This might allow accessing host resources. Ensure you trust the sandbox content.")
155158

159+
if _, err := ensureRunDir(sandboxHome); err != nil {
160+
return fmt.Errorf("docker: %w", err)
161+
}
162+
156163
listenPath := d.socketPath(sandboxHome)
164+
if err := checkSocketPath(listenPath); err != nil {
165+
return fmt.Errorf("docker: %w", err)
166+
}
167+
157168
d.proxy = dockerproxy.New(d.hostSocket, listenPath)
158169
if d.logger != nil {
159170
d.proxy.SetLogger(d.logger)

internal/sandbox/tools/docker_test.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"os"
55
"path/filepath"
66
"runtime"
7+
"strconv"
8+
"strings"
79
"testing"
810
)
911

@@ -96,11 +98,33 @@ func TestDocker_Environment_Enabled(t *testing.T) {
9698
t.Errorf("expected DOCKER_HOST, got %q", env[0].Name)
9799
}
98100

99-
// Socket path is homeDir/docker.sock (where sandboxHome is mounted inside sandbox)
100-
expected := "unix://" + homeDir + "/docker.sock"
101+
// Socket path is under homeDir, where sandboxHome is mounted inside the
102+
// sandbox, and mirrors the host-side path under sandboxHome.
103+
expected := "unix://" + filepath.Join(runDir(homeDir), "docker.sock")
101104
if env[0].Value != expected {
102105
t.Errorf("expected %q, got %q", expected, env[0].Value)
103106
}
107+
108+
hostPath := d.socketPath(sandboxHome)
109+
sandboxPath := strings.TrimPrefix(env[0].Value, "unix://")
110+
if rel := strings.TrimPrefix(hostPath, sandboxHome); rel != strings.TrimPrefix(sandboxPath, homeDir) {
111+
t.Errorf("socket path %q under sandbox home does not mirror %q under home dir", hostPath, sandboxPath)
112+
}
113+
}
114+
115+
// The socket must be private to this process: sandbox home is shared by every
116+
// session for the project, and Go unlinks the socket path on listener close,
117+
// so a shared path lets one session's exit break a live session's DOCKER_HOST.
118+
func TestDocker_SocketPath_IsProcessScoped(t *testing.T) {
119+
d := &Docker{enabled: true}
120+
path := d.socketPath("/sandbox/home")
121+
122+
if !strings.Contains(path, strconv.Itoa(os.Getpid())) {
123+
t.Errorf("expected docker socket path to be scoped to the PID, got %q", path)
124+
}
125+
if filepath.Dir(path) == "/sandbox/home" {
126+
t.Errorf("expected docker socket below sandbox home, got %q", path)
127+
}
104128
}
105129

106130
func TestDocker_Bindings(t *testing.T) {

internal/sandbox/tools/kitty.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func init() {
1717
}
1818

1919
const (
20-
kittyProxySocketName = ".kitty.sock"
20+
kittyProxySocketName = "kitty.sock"
2121
kittyModeAuto = "auto"
2222
kittyModeDisabled = "disabled"
2323
kittyModeEnforce = "enforce"
@@ -112,7 +112,7 @@ func (k *Kitty) Environment(homeDir, _ string) []EnvVar {
112112
return nil
113113
}
114114
return []EnvVar{
115-
{Name: "KITTY_LISTEN_ON", Value: "unix:" + filepath.Join(homeDir, kittyProxySocketName)},
115+
{Name: "KITTY_LISTEN_ON", Value: "unix:" + filepath.Join(runDir(homeDir), kittyProxySocketName)},
116116
{Name: "KITTY_WINDOW_ID", FromHost: true},
117117
{Name: "KITTY_PID", FromHost: true},
118118
}
@@ -199,7 +199,15 @@ func (k *Kitty) Start(ctx context.Context, _, sandboxHome string) error {
199199
Owned: owned,
200200
})
201201

202-
listenPath := filepath.Join(sandboxHome, kittyProxySocketName)
202+
if _, err := ensureRunDir(sandboxHome); err != nil {
203+
return fmt.Errorf("kitty: %w", err)
204+
}
205+
206+
listenPath := filepath.Join(runDir(sandboxHome), kittyProxySocketName)
207+
if err := checkSocketPath(listenPath); err != nil {
208+
return fmt.Errorf("kitty: %w", err)
209+
}
210+
203211
k.proxy = kittyproxy.New(hostSock, listenPath, filter, owned)
204212
if k.logger != nil {
205213
k.proxy.SetLogger(k.logger)

internal/sandbox/tools/kitty_test.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func TestKitty_AggregateAndStart_StartsProxyWhenConsumerPresent(t *testing.T) {
136136
k := &Kitty{}
137137
k.Configure(GlobalConfig{}, nil)
138138

139-
// sandboxHome must also be short: kitty's proxy listens at <sandboxHome>/.kitty.sock.
139+
// sandboxHome must also be short: kitty's proxy listens under <sandboxHome>.
140140
sandboxHome := shortSocketDir(t)
141141
if err := k.Start(context.Background(), sandboxHome, sandboxHome); err != nil {
142142
t.Fatalf("Start: %v", err)
@@ -146,7 +146,7 @@ func TestKitty_AggregateAndStart_StartsProxyWhenConsumerPresent(t *testing.T) {
146146
if k.proxy == nil {
147147
t.Fatal("expected proxy to be started when consumer present")
148148
}
149-
expected := filepath.Join(sandboxHome, ".kitty.sock")
149+
expected := filepath.Join(runDir(sandboxHome), kittyProxySocketName)
150150
if _, err := os.Stat(expected); err != nil {
151151
t.Errorf("proxy socket not created: %v", err)
152152
}
@@ -158,12 +158,56 @@ func TestKitty_AggregateAndStart_StartsProxyWhenConsumerPresent(t *testing.T) {
158158
listen = e.Value
159159
}
160160
}
161-
wantPrefix := "unix:" + filepath.Join(sandboxHome, ".kitty.sock")
161+
wantPrefix := "unix:" + expected
162162
if listen != wantPrefix {
163163
t.Errorf("KITTY_LISTEN_ON = %q, want %q", listen, wantPrefix)
164164
}
165165
}
166166

167+
// The proxy socket must be private to this process: sandbox home is shared by
168+
// every session for the project, and closing the listener unlinks the path, so
169+
// a shared path lets one session's exit break a live session's KITTY_LISTEN_ON.
170+
func TestKitty_SocketPath_IsProcessScoped(t *testing.T) {
171+
dir := shortSocketDir(t)
172+
upstream := filepath.Join(dir, "upstream.sock")
173+
l, err := net.Listen("unix", upstream)
174+
if err != nil {
175+
t.Fatal(err)
176+
}
177+
defer func() { _ = l.Close() }()
178+
179+
t.Setenv("KITTY_LISTEN_ON", "unix:"+upstream)
180+
Register(fakeKittyConsumer{})
181+
defer Unregister("fake-kitty-consumer")
182+
183+
k := &Kitty{}
184+
k.Configure(GlobalConfig{}, nil)
185+
186+
sandboxHome := shortSocketDir(t)
187+
if err := k.Start(context.Background(), sandboxHome, sandboxHome); err != nil {
188+
t.Fatalf("Start: %v", err)
189+
}
190+
defer func() { _ = k.Stop() }()
191+
192+
// A concurrent session's socket, in its own run dir under the same home.
193+
peerDir := filepath.Join(sandboxHome, runDirName, "1")
194+
if err := os.MkdirAll(peerDir, 0o700); err != nil {
195+
t.Fatal(err)
196+
}
197+
peerSocket := filepath.Join(peerDir, kittyProxySocketName)
198+
if err := os.WriteFile(peerSocket, nil, 0o600); err != nil {
199+
t.Fatal(err)
200+
}
201+
202+
if err := k.Stop(); err != nil {
203+
t.Fatalf("Stop: %v", err)
204+
}
205+
206+
if _, err := os.Stat(peerSocket); err != nil {
207+
t.Errorf("Stop removed a concurrent session's socket: %v", err)
208+
}
209+
}
210+
167211
func TestKitty_DisabledMode(t *testing.T) {
168212
dir := shortSocketDir(t)
169213
upstream := filepath.Join(dir, "upstream.sock")

internal/sandbox/tools/portal.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Portal struct {
2222
xdgRuntime string // XDG_RUNTIME_DIR value (from host env or /run/user/<uid>)
2323
logger ErrorLogger
2424
proxyCmd *exec.Cmd
25+
proxyDir string // host directory holding the proxy socket
2526
proxySocket string // path to the proxy socket file
2627
}
2728

@@ -70,8 +71,11 @@ func (p *Portal) SetLogger(logger ErrorLogger) {
7071
}
7172

7273
// proxySocketDir returns the directory for the proxy socket inside sandbox home.
74+
// It is private to this process so that a concurrent session for the same
75+
// project cannot unlink a live socket. Its own subdirectory keeps the other
76+
// tools' sockets out of the read-only bind published at XDG_RUNTIME_DIR.
7377
func (p *Portal) proxySocketDir(sandboxHome string) string {
74-
return filepath.Join(sandboxHome, ".dbus-proxy")
78+
return filepath.Join(runDir(sandboxHome), "dbus")
7579
}
7680

7781
// buildProxyArgs constructs xdg-dbus-proxy arguments.
@@ -107,9 +111,16 @@ func (p *Portal) Start(ctx context.Context, homeDir, sandboxHome string) error {
107111
return fmt.Errorf("portal: create proxy socket dir: %w", err)
108112
}
109113

114+
p.proxyDir = proxyDir
110115
p.proxySocket = filepath.Join(proxyDir, "bus")
111116

112-
// Remove stale socket from previous run
117+
// xdg-dbus-proxy reports an unbindable socket only by never creating it,
118+
// which would surface here as an opaque timeout.
119+
if err := checkSocketPath(p.proxySocket); err != nil {
120+
return fmt.Errorf("portal: %w", err)
121+
}
122+
123+
// Remove stale socket left by an earlier run that held this PID.
113124
_ = os.Remove(p.proxySocket)
114125

115126
proxyArgs := p.buildProxyArgs(busAddr, p.proxySocket)
@@ -140,9 +151,12 @@ func (p *Portal) Stop() error {
140151
_ = p.proxyCmd.Process.Kill()
141152
_ = p.proxyCmd.Wait()
142153

143-
// Clean up socket
144-
if p.proxySocket != "" {
145-
_ = os.Remove(p.proxySocket)
154+
// Clean up socket. The directory is private to this process, so removing it
155+
// whole cannot affect a concurrent session.
156+
if p.proxyDir != "" {
157+
if err := os.RemoveAll(p.proxyDir); err != nil {
158+
return fmt.Errorf("portal: remove proxy socket dir: %w", err)
159+
}
146160
}
147161
return nil
148162
}

internal/sandbox/tools/portal_integration_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,21 @@ func TestPortal_Integration_ProxyStartStop(t *testing.T) {
4444
}
4545

4646
// Verify socket exists
47-
proxySocket := filepath.Join(sandboxHome, ".dbus-proxy", "bus")
47+
proxySocket := filepath.Join(p.proxySocketDir(sandboxHome), "bus")
4848
if _, err := os.Stat(proxySocket); err != nil {
4949
t.Fatalf("proxy socket not found: %v", err)
5050
}
5151

52+
// A concurrent session's socket must survive this instance's teardown.
53+
peerDir := filepath.Join(sandboxHome, runDirName, "1", "dbus")
54+
if err := os.MkdirAll(peerDir, 0o700); err != nil {
55+
t.Fatal(err)
56+
}
57+
peerSocket := filepath.Join(peerDir, "bus")
58+
if err := os.WriteFile(peerSocket, nil, 0o600); err != nil {
59+
t.Fatal(err)
60+
}
61+
5262
// Stop proxy
5363
err = p.Stop()
5464
if err != nil {
@@ -59,4 +69,7 @@ func TestPortal_Integration_ProxyStartStop(t *testing.T) {
5969
if _, err := os.Stat(proxySocket); !os.IsNotExist(err) {
6070
t.Error("expected proxy socket to be cleaned up after Stop")
6171
}
72+
if _, err := os.Stat(peerSocket); err != nil {
73+
t.Errorf("Stop removed a concurrent session's socket: %v", err)
74+
}
6275
}

0 commit comments

Comments
 (0)