get2knowio / get2knowio/deacon
feat: Implement seamless IDE-independent host clipboard bridge via dynamic shims
- Dominant language
- Rust
- Stars
- 2
- Forks
- 0
- Avg merge
- 2h 36m
- Merged PRs (30d)
- 78
Description
## 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_.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 socat|nc|bash-/dev/tcp` per connection and dials `127.0.0.1:` **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:`, 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=,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 exec`s 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).
Contributor guide
Research direction
Start by reading the existing port-forward daemon and relay implementation in crates/core/src/port_forward/{mod.rs,daemon.rs,relay.rs}, then inspect the lifecycle and mount points named in crates/deacon/src/commands/up/lifecycle.rs and container.rs. The Phase 1 acceptance criteria define completion, but the relay strategy, clipboard backend, opt-in interface, and container-scoping design still need decisions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, rust
- Domain
- cli, devtools, infrastructure
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100