You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/xclipon 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
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?
Do we need Wayland vs X11 branching on Linux, and what's the WSL story (WSLg clipboard vs remoting to Windows clipboard)?
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.
Should this be gated behind a Dev Container Feature-like opt-in in devcontainer.json, a pure CLI flag, or both?
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)?
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).
Problem
Developers working inside a
deacon-managed devcontainer — viadeacon execinto 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 assumexclip/xsel(Linux) orpbcopy/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 nocrates/deacon/src/commands/shell.rs, andCLAUDE.md's Consumer-Only Scope lists exactlyup, down, exec, build, read-configuration, run-user-commands, templates apply, doctor. This issue reframes the trigger points asdeacon up(daemon lifecycle: start/stop) anddeacon 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
deaconhas solved.015-auto-forward-ports(crates/core/src/port_forward/{mod.rs,daemon.rs,registry.rs,relay.rs,detect.rs}, hidden entrypointcrates/deacon/src/commands/forward_daemon.rs) is the closest precedent and should shape this design directly:upre-execs the deacon binary itself with a hidden__forward-daemonsubcommand.daemon::daemonize()(daemon.rs:70-99) callsnix::unistd::setsid()to detach from the controlling terminal, then redirects stdio to a per-container log file — all via safenixwrappers, nounsafe(workspace hasunsafe_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.forwarded_ports.jsonunderuser_data_folder, guarded by anfs2advisory flock, written via the temp-file+renameatomic pattern) plus a per-containerforward_daemon_<container_id>.pidmarker and.logfile (port_forward/mod.rs,daemon.rs:619-656).daemon.rs:233-263);downlocates the marker and sends SIGTERM viaterminate_pid()/pid_alive()(daemon.rs:110-153).host.docker.internal, gateway IPs) entirely — it spawnsdocker exec -i <id> socat|nc|bash-/dev/tcpper connection and dials127.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 forhost.docker.internal/gateway access (confirmed: no hits indocker.rs). That resolution differs across Docker Desktop (macOS/Windows, auto-injectshost.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_forwarddaemon skeleton (re-exec with a hidden subcommand,setsiddetach, pidfile/log underuser_data_folder, SIGTERM-driven shutdown keyed to container liveness) rather than inventing a new daemon-lifecycle mechanism. New pieces:127.0.0.1:<port>, never0.0.0.0) — same posture as the port-forward listener.arboardor platform-native calls (X11/Wayland via a portal on Linux, NSPasteboard viaobjc/cocoabindings on macOS, or shelling out topbcopy/pbpaste/wl-copy/xclipon 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).GET /clipboard(returns MIME type + payload; text orimage/png/image/jpeg) andPOST /clipboard(sets host clipboard from container-supplied payload).2. Container-side shims
xclip,xsel,pbcopy,pbpastethat translate their respective CLI argument shapes into the daemon's HTTP protocol (e.g.curl/wget-free — a tiny statically-linked Rust orbusybox-compatible shell shim, since we can't assumecurlexists in every base image).crates/deacon/src/commands/up/container.rs:452-513): write the shim tree to a host-side, machine-owned directory (underuser_data_folder, not the workspace — this is deacon tooling, not workspace-authored content) and pushtype=bind,source=<host_shim_dir>,target=/opt/deacon/bin,readonlyintomerged_mounts.mountsalongside the other programmatic mounts.3. PATH prepending
No new mechanism needed — follow the existing precedent:
export PATH="/usr/local/sbin:...:${PATH:-}"prefix (docker.rs:2308-2328) to prepend/opt/deacon/binfirst, so shims win over any realxclip/pbpastea feature might have installed.build_effective_env()'s existingremoteEnv/containerEnvPATH-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:
docker execreverse-relay, mirroringrelay.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 allhost.docker.internal/gateway-resolution questions, and works identically across Docker/Podman/rootless. Con:docker execper clipboard operation has some latency (likely fine for a human-initiated paste).host.docker.internal(Docker Desktop) with a--add-host=host.docker.internal:host-gatewaymount added on Linux, and a to-be-determined Podman rootless gateway strategy. Pro: lower latency, nodocker execper 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 execas the only container-facing surface). Revisit (B) only ifdocker execlatency proves unacceptable in practice.Security / trust model
This does not fit the existing
WorkspaceTrustPolicygate (crates/core/src/trust.rs). That gate is scoped specifically to executing workspace-authored shell content on the developer's host (initializeCommand, dotfilesinstallCommand— seetrust.rs:1-16, enforced viaenforce_host_trust()inup/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'sonAutoForward/DEACON_BROWSERsplit (perSECURITY.md): the workspace config can request the bridge be available (e.g. adevcontainer.jsonfeature/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 asettings.jsonentry — never silently auto-enabled just because a workspace asks for it. Needs:--helpand docs about what it exposes.0.0.0.0), matching the port-forward posture.SECURITY.md's threat model once implemented.Open questions
arboardvs 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?docker exec-per-call model was designed for small/frequent port traffic, not necessarily large binary payloads.devcontainer.json, a pure CLI flag, or both?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
port_forwarddaemon skeleton),xclip/pbpaste/pbcopyshims,docker exec-relay reachability (strategy A), text/plain MIME only, opt-in flag, loopback bind, nextest coverage mirroringport_forward's test groups.image/png,image/jpeg), payload-size limits, latency validation for strategy (A).xselshim, Windows host clipboard (if in scope).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 viadocker inspect/marker-file assertions matchingport_forward's existing test patterns.pbpaste/xclip -o/xclip -selection clipboard -oreturn the exact bytes present on the host clipboard at call time (text case).pbcopy < file.txt/echo foo | xclip -selection clipboardinside the container updates the host clipboard, verified by reading it back on the host.deacon upwith 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 asport_forward.cargo clippy --all-targets --all-features -- -D warningsandcargo fmt --all -- --checkclean; new integration tests correctly grouped in.config/nextest.toml(likelydocker-sharedordocker-exclusive, mirroring howport_forward's tests are grouped).