Skip to content

feat: Implement seamless IDE-independent host clipboard bridge via dynamic shims #281

Description

@pofallon

Problem

Developers working inside a deacon-managed devcontainer — via deacon exec into an interactive shell, or via any tool/agent (e.g. Claude Code) running inside the container — have no path to the host system clipboard. Tools that assume xclip/xsel (Linux) or pbcopy/pbpaste (macOS) either aren't installed in the container image, or if installed, operate on the container's own (nonexistent/isolated) X11/Wayland/NSPasteboard, not the developer's desktop clipboard. This breaks workflows where an in-container agent needs to read an image or text snippet the developer just copied on the host, or push a result back to the host clipboard for pasting elsewhere.

There is no Dev Container Feature that can fix this generically, because the fundamental capability — reaching back out to the host desktop session — doesn't exist from inside the container network namespace today.

Note on the original ask: the request described this triggering on deacon shell. That subcommand does not exist — confirmed no crates/deacon/src/commands/shell.rs, and CLAUDE.md's Consumer-Only Scope lists exactly up, down, exec, build, read-configuration, run-user-commands, templates apply, doctor. This issue reframes the trigger points as deacon up (daemon lifecycle: start/stop) and deacon exec (where a user lands in an interactive shell), consistent with the existing subcommand surface.

Prior art in this codebase

This is not the first "container needs to reach something on the host" problem deacon has solved. 015-auto-forward-ports (crates/core/src/port_forward/{mod.rs,daemon.rs,registry.rs,relay.rs,detect.rs}, hidden entrypoint crates/deacon/src/commands/forward_daemon.rs) is the closest precedent and should shape this design directly:

  • Daemon spawn: up re-execs the deacon binary itself with a hidden __forward-daemon subcommand. daemon::daemonize() (daemon.rs:70-99) calls nix::unistd::setsid() to detach from the controlling terminal, then redirects stdio to a per-container log file — all via safe nix wrappers, no unsafe (workspace has unsafe_code = "deny"). Non-Unix hosts get a hard "unsupported platform" error (daemon.rs:101-108) rather than a silent no-op, per constitution Principle IV.
  • State tracking: a host-global registry (forwarded_ports.json under user_data_folder, guarded by an fs2 advisory flock, written via the temp-file+rename atomic pattern) plus a per-container forward_daemon_<container_id>.pid marker and .log file (port_forward/mod.rs, daemon.rs:619-656).
  • Lifecycle: the daemon selects on SIGTERM and self-exits when it detects the container is gone (daemon.rs:233-263); down locates the marker and sends SIGTERM via terminate_pid()/pid_alive() (daemon.rs:110-153).
  • Important direction mismatch: the port-forward daemon solves host→container reachability, and it deliberately avoids raw container→host networking (host.docker.internal, gateway IPs) entirely — it spawns docker exec -i <id> socat|nc|bash-/dev/tcp per connection and dials 127.0.0.1:<port> inside the container's own netns (relay.rs). A clipboard bridge needs the opposite direction (container asks host "what's on your clipboard"), so this codebase currently has zero existing address-resolution logic for host.docker.internal/gateway access (confirmed: no hits in docker.rs). That resolution differs across Docker Desktop (macOS/Windows, auto-injects host.docker.internal), Docker on Linux (needs --add-host=host.docker.internal:host-gateway), and Podman's rootless netns (different gateway again). This is new surface, not an extension of existing code — see Open Questions.

Proposed architecture

1. Host-side clipboard daemon

Reuse the port_forward daemon skeleton (re-exec with a hidden subcommand, setsid detach, pidfile/log under user_data_folder, SIGTERM-driven shutdown keyed to container liveness) rather than inventing a new daemon-lifecycle mechanism. New pieces:

  • Bind loopback only (127.0.0.1:<port>, never 0.0.0.0) — same posture as the port-forward listener.
  • Platform clipboard access: arboard or platform-native calls (X11/Wayland via a portal on Linux, NSPasteboard via objc/cocoa bindings on macOS, or shelling out to pbcopy/pbpaste/wl-copy/xclip on the host where already present) behind a trait so the daemon fails loud with a clear error if no backend is available, rather than silently no-oping (Principle IV).
  • Protocol: minimal HTTP or length-prefixed framed TCP supporting GET /clipboard (returns MIME type + payload; text or image/png/image/jpeg) and POST /clipboard (sets host clipboard from container-supplied payload).

2. Container-side shims

  • Ship small static binaries or POSIX-sh scripts for xclip, xsel, pbcopy, pbpaste that translate their respective CLI argument shapes into the daemon's HTTP protocol (e.g. curl/wget-free — a tiny statically-linked Rust or busybox-compatible shell shim, since we can't assume curl exists in every base image).
  • Mount them read-only using the same bind-mount injection pattern as the entrypoint-wrapper (crates/deacon/src/commands/up/container.rs:452-513): write the shim tree to a host-side, machine-owned directory (under user_data_folder, not the workspace — this is deacon tooling, not workspace-authored content) and push type=bind,source=<host_shim_dir>,target=/opt/deacon/bin,readonly into merged_mounts.mounts alongside the other programmatic mounts.

3. PATH prepending

No new mechanism needed — follow the existing precedent:

  • At container-create time, extend the existing export PATH="/usr/local/sbin:...:${PATH:-}" prefix (docker.rs:2308-2328) to prepend /opt/deacon/bin first, so shims win over any real xclip/pbpaste a feature might have installed.
  • At exec/lifecycle time, thread it through build_effective_env()'s existing remoteEnv/containerEnv PATH-append layering (container_env_probe.rs:593-648), not a bespoke path.

4. Container → host reachability (the actual new work)

Two viable strategies, not mutually exclusive:

  • (A) docker exec reverse-relay, mirroring relay.rs. The shim writes its request to a well-known FIFO/socket inside the container; a per-container relay task (spawned by the host daemon, same shape as the existing port-forward relay but direction-reversed) docker execs in, reads the request, and answers over the same channel. Pro: reuses a proven pattern in this codebase, avoids all host.docker.internal/gateway-resolution questions, and works identically across Docker/Podman/rootless. Con: docker exec per clipboard operation has some latency (likely fine for a human-initiated paste).
  • (B) Direct TCP dial-out from the shim to the host daemon, using host.docker.internal (Docker Desktop) with a --add-host=host.docker.internal:host-gateway mount added on Linux, and a to-be-determined Podman rootless gateway strategy. Pro: lower latency, no docker exec per call. Con: real new cross-platform/cross-runtime address-resolution code, and a raw TCP listener reachable from any process in the container (broader attack surface than an exec-gated channel) — needs its own auth story (e.g. a per-container random token baked into the shim's env at mount time, checked by the daemon).

Recommendation: start with (A) for the MVP — it's a incremental extension of already-reviewed, already-tested daemon/relay code, sidesteps the cross-platform gateway problem entirely, and keeps the trust boundary identical to the existing port-forward feature (loopback-only host bind, docker exec as the only container-facing surface). Revisit (B) only if docker exec latency proves unacceptable in practice.

Security / trust model

This does not fit the existing WorkspaceTrustPolicy gate (crates/core/src/trust.rs). That gate is scoped specifically to executing workspace-authored shell content on the developer's host (initializeCommand, dotfiles installCommand — see trust.rs:1-16, enforced via enforce_host_trust() in up/lifecycle.rs:430-459). A clipboard bridge doesn't execute workspace-supplied commands; it opens a host listener that answers container-issued clipboard get/set requests — a different threat shape (data exfiltration of whatever's on the developer's clipboard, or injection of arbitrary clipboard content, to/from any process running in the container, including one installed by a compromised feature or a prompt-injected agent).

Design should follow the existing precedent set for --auto-forward's onAutoForward/DEACON_BROWSER split (per SECURITY.md): the workspace config can request the bridge be available (e.g. a devcontainer.json feature/setting), but the machine owner controls whether it's actually enabled, via an explicit CLI flag (e.g. --enable-clipboard-bridge, off by default) and/or a settings.json entry — never silently auto-enabled just because a workspace asks for it. Needs:

  • Opt-in default-off flag, loud in --help and docs about what it exposes.
  • Loopback-only host bind (never 0.0.0.0), matching the port-forward posture.
  • If strategy (B) is ever pursued: per-container auth token so an unrelated process on the same Docker network can't read another container's clipboard bridge.
  • Explicit note in SECURITY.md's threat model once implemented.

Open questions

  1. Which clipboard backend crate/approach for the host daemon (arboard vs shelling out to platform tools vs native FFI), and how do we detect "no clipboard available" (headless Linux CI/server) and fail with a clear error rather than hanging?
  2. Do we need Wayland vs X11 branching on Linux, and what's the WSL story (WSLg clipboard vs remoting to Windows clipboard)?
  3. Confirm relay approach (A) latency is acceptable for image payloads (PNG screenshots can be several MB) before committing — the port-forward relay's docker exec-per-call model was designed for small/frequent port traffic, not necessarily large binary payloads.
  4. Should this be gated behind a Dev Container Feature-like opt-in in devcontainer.json, a pure CLI flag, or both?
  5. How is the daemon told which container(s) it should serve — same container-id keyed marker/registry approach as port_forward, or does clipboard access need to be workspace-scoped instead (one clipboard daemon can arguably serve multiple containers safely, unlike per-port state)?

Suggested implementation phases

  • Phase 1 — MVP text-only bridge (Linux + macOS host, Docker only): host daemon (reusing port_forward daemon skeleton), xclip/pbpaste/pbcopy shims, docker exec-relay reachability (strategy A), text/plain MIME only, opt-in flag, loopback bind, nextest coverage mirroring port_forward's test groups.
  • Phase 2 — Image MIME support (image/png, image/jpeg), payload-size limits, latency validation for strategy (A).
  • Phase 3 — Podman support, WSL/WSLg validation, xsel shim, Windows host clipboard (if in scope).
  • Phase 4 (stretch) — revisit direct TCP dial-out (strategy B) only if (A)'s latency is a proven blocker, with the per-container auth token design fleshed out first.

Acceptance criteria (Phase 1)

  • deacon up --enable-clipboard-bridge (flag name TBD) starts a host daemon analogous to the port-forward daemon's lifecycle (spawn/marker/log/SIGTERM-on-teardown), verified via docker inspect/marker-file assertions matching port_forward's existing test patterns.
  • Inside the container, pbpaste/xclip -o/xclip -selection clipboard -o return the exact bytes present on the host clipboard at call time (text case).
  • pbcopy < file.txt / echo foo | xclip -selection clipboard inside the container updates the host clipboard, verified by reading it back on the host.
  • Feature is fully opt-in — deacon up with no flag produces byte-identical behavior to today (no listener started, no shims mounted, no PATH change).
  • down (and container removal generally) leaves no orphaned daemon process or stale marker/log file — same guarantee as port_forward.
  • cargo clippy --all-targets --all-features -- -D warnings and cargo fmt --all -- --check clean; new integration tests correctly grouped in .config/nextest.toml (likely docker-shared or docker-exclusive, mirroring how port_forward's tests are grouped).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementFeature enhancementfeatNew feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions