Перейти к содержимому

Хост-бридж

Хост-бридж позволяет выполнять команды dwe изнутри dev-контейнеров — git-хуки (exec dwe commands lint), проектные команды и read-only диагностика работают одинаково на хосте и в терминале контейнера (VS Code Dev Containers и аналоги). DWE монтирует небольшой статический shim-бинарник в контейнеры bridge-включённых сервисов под именем dwe; shim пересылает каждый вызов хостовому демону, который запускает настоящий dwe на хосте и транслирует вывод и код выхода обратно.

Бридж по умолчанию выключен для всех типов сервисов — сервис подключается явно через bridge.enabled: true. Всё ниже — справочные детали: схема bridge:, транспорты, политика команд внутри контейнера, генерируемый compose-оверлей, жизненный цикл демона и устранение неполадок.

Суть бриджа проста: внутри bridge-включённого контейнера можно просто запускать dwe — набрав в шелле, из git-хука или из команды проекта — и он ведёт себя так же, как на хосте. Это обеспечивают два процесса: крошечный shim подменяет dwe внутри контейнера, а хостовый демон запускает настоящий dwe. Контейнер никогда не исполняет логику DWE — он только пересылает.

flowchart LR
  subgraph container["dev-контейнер (bridge-включённый сервис)"]
    direction TB
    Caller["dwe &lt;команда&gt;<br/>шелл · git-хук · команда проекта"]
    Shim["shim dwe<br/>статический бинарник ~3 МБ"]
    Caller --> Shim
  end

  subgraph host["хост"]
    direction TB
    Daemon["демон бриджа<br/>stateless-форвардер"]
    RealDwe["настоящий dwe<br/>форк на соединение"]
    Daemon --> RealDwe
  end

  Shim -->|"unix-сокет — нативный Linux"| Daemon
  Shim -.->|"TCP + токен — Docker Desktop / OrbStack / WSL2"| Daemon

Любой вызов транслируется из конца в конец и возвращает хостовый код выхода:

sequenceDiagram
  participant H as вызывающий в контейнере
  participant S as shim dwe
  participant D as хостовый демон
  participant R as настоящий dwe (хост)

  H->>S: dwe …
  Note over S: выбор транспорта —<br/>unix-сокет, иначе TCP + токен
  S->>D: команда + argv + cwd + env
  Note over D: трансляция cwd в хостовый путь,<br/>strip/force env, политика команд
  D->>R: форк "dwe …"
  R-->>D: stdout / stderr транслируются
  D-->>S: поток вывода + код выхода
  S-->>H: тот же код выхода
  • Shim — статический Linux-бинарник ~3 МБ (сборки amd64 и arm64 встроены в хостовый dwe и материализуются в .dwe/bridge/). Он не несёт бизнес-логики: выбирает транспорт, отправляет команду, прокачивает stdio и завершается с хостовым кодом выхода.
  • Демон (dwe bridge daemon, скрытая команда) — stateless-форвардер: одно соединение на команду, форк dwe <argv> на соединение, без модели проекта, без кэша. Он запускается и останавливается автоматически lifecycle-командами.
  • Рабочая директория shim транслируется из контейнерного пути в хостовый (см. Контракт окружения), поэтому относительные пути в хуках и проектных командах просто работают.

Опт-ин на уровне сервиса в workspace/services/<name>/service.yml:

type: app
dir: ./services/main
dir_internal: /var/www/html
bridge:
enabled: true # по умолчанию: false — бридж строго опт-ин
# shim_path: /usr/local/bin/dwe # переопределение точки монтирования (коллизия с базовым образом)
# on_unreachable: fail # fail | warn — политика shim при недоступном демоне
ПолеПо умолчаниюЗначение
enabledfalse (для всех типов)монтировать ли shim-бинарник и bridge-маунты в контейнер этого сервиса
shim_path/usr/local/bin/dweабсолютный контейнерный путь, по которому монтируется shim; переопределите, если базовый образ уже содержит файл по этому пути
on_unreachablefailfail — shim печатает ошибку и выходит с кодом 1, когда хостовый демон недоступен (хук блокирует коммит); warn — печатает предупреждение и выходит с кодом 0

bridge.enabled — тристейт и наследуется через extends: сервиса так же, как render.git.enabled: явное значение в потомке побеждает, незаданное наследуется от родителя, а дефолт «выключено» применяется только когда не задано нигде. shim_path и on_unreachable наследуются, когда у потомка они пусты.

Bridge-включённому сервису следует объявить пару dir / dir_internal — именно по ней shim транслирует рабочие директории. Без неё трансляции рабочих директорий нет, и каждый вызов из контейнера выполняется из корня проекта, а не из текущей директории (dwe validate предупреждает об этом).

Shim и демон всегда поддерживают оба транспорта; детекции платформы нет ни на одной стороне.

ПлатформаРабочий транспортАутентификация
Linux (нативный Docker)unix-сокет .dwe/bridge/host.sockuid пира должен совпадать с uid демона (SO_PEERCRED)
Linux rootlessunix-сокет (host-gateway там сломан)uid пира
Docker Desktop (macOS / Windows)TCP host.docker.internal:<port> (Desktop проксирует на loopback-листенер)256-битный пер-проектный токен
OrbStack / ColimaTCPтокен
WSL2 (dwe внутри дистрибутива)TCPтокен

Выбор, на каждый вызов:

  1. Если в примонтированной bridge-директории есть host.sock — подключение к нему с таймаутом 300 мс. На Docker Desktop и подобных рантаймах инода сокета видна, но мертва (file sharing не пробрасывает connect() через границу ВМ), поэтому отказ мгновенный — так и задумано.
  2. Фолбэк на TCP: чтение файлов port и token из bridge-директории (с короткими повторами — рестарт демона оставляет небольшое окно) и подключение к host.docker.internal:<port> с предъявлением токена.
  3. Оба не сработали → применяется политика недоступности.
flowchart TD
  Start["shim'у нужен демон"] --> Sock{"host.sock есть<br/>и подключается за 300 мс?"}
  Sock -->|да| Unix["unix-сокет<br/>auth: peer uid (SO_PEERCRED)"]
  Sock -->|"нет / мёртвая инода"| TCP{"файлы port + token<br/>читаются?"}
  TCP -->|да| Conn["TCP к host.docker.internal:port<br/>auth: 256-битный токен"]
  TCP -->|нет| Unreach["политика недоступности<br/>fail → exit 1 · warn → exit 0"]

Демон всегда слушает unix-сокет и 127.0.0.1 с эфемерным портом, выданным ОС (коллизии портов невозможны, нет экспозиции в LAN — никогда 0.0.0.0). На нативном Linux он дополнительно биндит IP docker-шлюза (обычно 172.17.0.1), чтобы контейнеры могли его достичь; генерируемый оверлей добавляет extra_hosts: host.docker.internal:host-gateway каждому bridged-сервису — это обязательно на Linux и безвредно в остальных случаях. Для экзотических конфигураций адреса прослушивания переопределяются переменной окружения DWE_BRIDGE_BIND (список адресов через запятую или пробелы) при запуске демона. Wildcard-адреса (0.0.0.0, ::) в переопределении отклоняются — привязка ко всем интерфейсам нарушила бы гарантию отсутствия экспозиции в LAN, поэтому такая запись игнорируется с предупреждением, и демон возвращается к loopback по умолчанию.

Изоляция — попроектная: у каждого проекта свои сокет, порт и токен в собственной .dwe/bridge/; контейнер одного проекта, подключившийся к порту другого, не пройдёт аутентификацию, а демон никогда не выполняет команды вне корня своего проекта — внепроектная рабочая директория заменяется корнем проекта.

Командная поверхность в контейнере сознательно урезана — allowlist, default-deny. Два мотива: dwe stop изнутри контейнера остановил бы контейнер самого вызывающего (сессия терминала/IDE умрёт до получения результата), а токен в скомпрометированном контейнере не должен позволять уничтожать данные.

Разрешено из контейнераПочему
dwe commands <cmd> / dwe cmd <cmd>основной сценарий — хуки и проектные команды; только команды с opt-in через bridge:
dwe commands / dwe cmd без аргументовчерез бридж нет TTY → печатается вывод commands list (отфильтрованный до bridged-команд) вместо интерактивного браузера
status, info, logsread-only диагностика
docs (включая llms-txt)read-only; полезно AI-агентам, работающим в devcontainer — dwe docs без аргументов печатает вывод docs list (нет TTY для браузера)
promptсегмент промпта терминала контейнера
vars (get/list/inspect; set ограничен bridge.vars_writable)чтение песочницы vars; запись из контейнера запрещена по умолчанию согласно allowlist верхнего уровня bridge.vars_writable
render configперегенерация конфиг-файлов после vars set на стороне контейнера (--harvest остаётся только хостовым); прочие подкоманды render (env/ide/ai/git) — только хостовые
bridge statusсамодиагностика бриджа
version, helpслужебные команды
ЗаблокированоПочему
stop, restart, resetсуицидально: останавливают / пересоздают контейнер, из которого вызваны
deploy, run, servicesстек управляется с хоста
snapshotrestore останавливает стек; create тяжёлая и берёт проектные блокировки
render (кроме render config), setup, init, shellмутируют файлы воркспейса или интерактивны — выделена только render config (см. выше)
bridge (всё, кроме status)bridge stop — суицид для самого бриджа
validate, completionхостовые задачи: валидация относится к хостовому воркспейсу, completion-скрипты устанавливаются на хосте (уже запечённый в образ completion-скрипт продолжит молча деградировать — скрытая completion-механика остаётся доступной)

Внутри разрешённой поверхности dwe cmd / dwe commands есть второй, покомандный гейт: пользовательская команда достижима из контейнера, только если её определение явно включено блоком bridge: (на команде или в заголовке group: её файла) — см. директивы команд § Видимость через bridge. Без него команда остаётся host-only: отфильтрована из контейнерных листингов и completion, а прямой вызов отклоняется ошибкой command_not_bridged с подсказкой, как исправить.

commands:
cs.all:
type: service_exec
cmd: composer cs
bridge:
enabled: true # доступна из любого bridged-контейнера
services: [main] # …или только из этих (ключи сервисов воркспейса)

bridge.services сверяется с идентичностью вызывающего контейнера, которую overlay инжектит как DWE_BRIDGE_SERVICE=<ключ сервиса>, а shim пересылает дальше. Идентичность сообщается контейнером и потому advisory — это UX-граница (например, не показывать php-команды в листинге nginx-контейнера), а не граница безопасности; границей безопасности остаётся верхнеуровневый allowlist выше плюс env-хардненинг демона. Матчинг учитывает extends:: сервис, расширяющий другой, наследует права родителя на команды — services: [main] пускает и контейнеры сервисов, расширяющих main (но не наоборот). Исполнение сценариев не гейтится никогда: bridged-сценарий, как обычно, выполняет свои не-bridged подкоманды на хосте.

Механика, которую стоит знать:

  • Заблокированная команда завершается ошибкой bridge_command_blocked с подсказкой запустить на хосте; суицидальные lifecycle-команды дополнительно объясняют почему (например, «она остановила бы контейнер, из которого вызвана»).
  • Заблокированные команды невидимы в списках --help и в shell-комплишене внутри контейнеров — а не «видимы, но падают».
  • Политика наследуется вложенными вызовами: пользовательская команда type: dwe порождает дочерний dwe с тем же окружением, так что хук, прячущий dwe stop внутри проектной команды, не сможет убить контейнер.
  • Каждая bridged-команда выполняется неинтерактивно (плоский прогресс-вывод, без промптов). Интерактивные TUI заблокированы полностью; сам протокол бриджа никогда не выделяет псевдотерминал. Цвета при этом сохраняются: когда stdout у shim — терминал, он пересылает CLICOLOR_FORCE=1 и COLORTERM=truecolor, и хостовый dwe оставляет ANSI-цвета и полную хостовую палитру поверх пайпа бриджа (help и длинный вывод на пайпах всегда рендерятся тёмной палитрой — тот же дефолт, что у lipgloss). А когда stdin И stdout контейнера — терминалы, shim добавляет DWE_BRIDGE_STDIN_TTY=1, и хостовые раннеры дают детям пользовательских команд локальный PTY — docker compose exec тогда выделяет TTY в контейнере, и isatty-инструменты (phpcs, PHPUnit, ripgrep, …) красятся ровно как при запуске на хосте, со stderr, слитым в stdout, как в любой терминальной сессии. NO_COLOR в контейнере отключает это; пайп вывода shim (dwe cmd foo | grep … или cat dump.sql | dwe cmd db.import) автоматически остаётся плоским и без PTY.

.dwe/compose.bridge.ymlмашинное состояние, принадлежащее dwe — та же модель владения, что у генерируемого .env и отрендеренных конфигов сервисов. Каждая команда, выполняющая compose up (dwe deploy run, dwe run, dwe services … --apply, рестарт всего стека), атомарно регенерирует его перед стартом стека; когда ни один включённый сервис не имеет включённого бриджа, файл удаляется. Ручные правки не поддерживаются и перезаписываются — об этом сказано в заголовке файла.

# GENERATED by dwe — do not edit; customize via workspace/local.yml
services:
app-main:
volumes:
- type: bind
source: /Users/foo/projects/my-proj/.dwe/bridge
target: /dwe-bridge
read_only: true
- type: bind
source: /Users/foo/projects/my-proj/.dwe/bridge/shim-linux-arm64
target: /usr/local/bin/dwe
read_only: true
environment:
DWE_BRIDGE_DIR: /dwe-bridge
DWE_HOST_WORKSPACE: /Users/foo/projects/my-proj/services/main
DWE_CONTAINER_WORKSPACE: /var/www/html
DWE_BRIDGE_PROJECT: my-proj
DWE_BRIDGE_SERVICE: main
extra_hosts:
- host.docker.internal:host-gateway
  • Позиция в цепочке: оверлей стоит после собственных compose-файлов проекта и до оверлеев workspace/local.yml — за local.yml остаётся последнее слово поверх всего, что задаёт бридж (в compose побеждает более поздний файл), так что попроектные/персональные кастомизации идут через local.yml, а не через правку генерируемого файла.
  • Самовосстановление: файл всегда содержит ровно текущие включённые и bridge-включённые сервисы, поэтому отключение сервиса не может оставить устаревший фрагмент, ломающий compose up. Перемещённая директория проекта, сменившаяся архитектура образа и отредактированные настройки bridge: подхватываются при следующем старте.
  • Архитектура shim выбирается на сервис при каждой регенерации по архитектуре образа (docker inspect по контейнеру <compose-имя проекта>-<container> с учётом project_name из docker.yml); когда контейнер ещё не существует (первый деплой, после reset), молча используется архитектура хоста (решение фиксируется строкой при -v), а любая другая ошибка резолва даёт тот же фолбэк с предупреждением — оба случая самовосстанавливаются при следующей регенерации.
  • TCP-порт и токен сознательно не входят в оверлей — shim читает их из примонтированных файлов /dwe-bridge, поэтому рестарт демона никогда не требует регенерации оверлея или пересоздания контейнеров.
  • Все маунты read-only; подключение к unix-сокету работает на read-only bind-маунте (тот же механизм, благодаря которому работает docker.sock:ro).

Runtime-директория бриджа на хосте (.dwe/ в gitignore):

ФайлКто пишетНазначение
host.sockдемон, при стартеunix-сокет-транспорт (нативный Linux)
portдемон, после bindфактический эфемерный TCP-порт
tokenдемон, при первом старте (режим 0600)256-битный токен аутентификации; стабильная идентичность проекта, переживает рестарты демона
daemon.pidдемонPID + держатель flock — проба живости
daemon.logдемонstderr демона (append-only; без ротации)
shim-linux-amd64, shim-linux-arm64хостовый dwe, при каждом prepareматериализованные shim-бинарники (источники bind-маунтов)

Сам оверлей живёт уровнем выше — .dwe/compose.bridge.yml.

Демон управляется автоматически — каждая lifecycle-команда, утверждающая «стек жив», сначала обеспечивает его наличие:

КомандаДействие с демоном
dwe deploy runcycle (стоп → старт) — гарантирует, что демон не от старой сборки dwe
dwe runensure (запустить, если не работает)
dwe restart (весь стек)cycle
`dwe services enabledisable —apply`
dwe status (верхнего уровня)best-effort ensure
dwe bridge startensure (вручную)
dwe stop / dwe reset run (весь стек)стоп
dwe stop <svc> / reset run --service <svc> / restart <svc>не трогают

Демон также автоостанавливается, когда стек действительно погашен: он следит за docker-событиями контейнеров проекта (с poll-фолбэком раз в 60 с) и завершается, когда их не остаётся, удаляя host.sock и port, но сохраняя token. Idle-таймаута сознательно нет — мёртвого демона нельзя оживить изнутри контейнера, поэтому его убийство под активным разработчиком сломало бы хуки.

Когда в проекте нет bridge-включённого сервиса, демон никогда не запускается и оверлей не генерируется.

Ручное управление и диагностика; в повседневной работе lifecycle-команды делают их ненужными. Все поддерживают --output json.

КомандаПоведение
dwe bridge startзапустить демон (идемпотентно); отказывает с bridge_not_enabled, когда ни у одного включённого сервиса бридж не включён
dwe bridge stopSIGTERM демону по pidfile (идемпотентно); dwe в контейнере перестаёт работать до повторного запуска
dwe bridge statusживость (pid, аптайм), эндпоинты транспортов (путь сокета, TCP-порт), состояние материализации shim; единственная подкоманда bridge, разрешённая изнутри контейнеров
dwe bridge logs [--tail N]показать .dwe/bridge/daemon.log (по умолчанию последние 50 строк, --tail 0 = все)

Внутри bridged-контейнера оверлей задаёт:

ПеременнаяЗначение
DWE_BRIDGE_DIRточка монтирования хостовой .dwe/bridge в контейнере (/dwe-bridge)
DWE_HOST_WORKSPACE / DWE_CONTAINER_WORKSPACEхостовая hub-директория сервиса (<root>/<dir>) и её маунт в контейнере (dir_internal) — пара префиксов, по которой shim переписывает рабочие директории
DWE_BRIDGE_PROJECTимя проекта, используется в диагностике shim
DWE_BRIDGE_SERVICEключ сервиса в воркспейсе — идентичность вызывающего контейнера, с которой сверяются покомандные списки bridge.services. Единственная bridge-переменная, которая ПЕРЕСЫЛАЕТСЯ на хост (её потребитель — хостовый dwe); сообщается контейнером, поэтому advisory
DWE_BRIDGE_UNREACHABLEприсутствует только при on_unreachable: warn

Shim вычищает их — кроме DWE_BRIDGE_SERVICE — (плюс любые DWE_PROJECT_ROOT*) из пересылаемого окружения, а демон повторно фильтрует тот же набор на приёме. Демон также отбрасывает переменные, способные перехватить запуск процессов, перед форком — семейства динамического загрузчика (LD_*, DYLD_*), хуки запуска оболочки (BASH_ENV, ENV, SHELLOPTS, BASHOPTS), IFS и PATH — и принудительно задаёт PATH равным собственному значению хостового демона, чтобы контейнер не мог подменить бинарники docker/git/sh, которые хостовый dwe вызывает по короткому имени. Переменные хостовой идентичности заменяются так же: контейнерные HOME, USER, LOGNAME, TMPDIR, SSH_AUTH_SOCK и семейства DOCKER_* / COMPOSE_* / XDG_* отбрасываются, вместо них пересылаются собственные значения демона — контейнерный HOME иначе ломал бы резолв docker-контекста на хосте (CLI откатился бы на /var/run/docker.sock, которого нет на маках с Docker Desktop / OrbStack). Затем демон принудительно задаёт форкаемому dwe две контролируемые хостом переменные: DWE_INVOKED_FROM=container (активирует политику команд — присланные клиентом значения отбрасываются, подделать из контейнера нельзя) и DWE_NONINTERACTIVE=1. Полезные нагрузки --output json идентичны в обоих контекстах.

Вектор аргументов команды передаётся без трансляции — переписывается только рабочая директория. Поэтому относительные пути работают везде; абсолютные контейнерные пути в аргументах не разрешатся на хосте (документированное ограничение).

  • Никаких интерактивных команд. Бридж никогда не выделяет псевдотерминал; политика блокирует интерактивные команды, а всё остальное работает в том же неинтерактивном режиме, что и в CI.
  • Абсолютные контейнерные пути в аргументах не транслируются — используйте относительные (рабочая директория транслируется автоматически).
  • Сервисы без пары dir / dir_internal не получают трансляции рабочих директорий; вызовы из контейнера тогда выполняются из корня проекта, а не из текущей директории. dwe validate это подсвечивает.
  • Windows-контейнеры и dwe на стороне Windows-хоста вне области применения; WSL2 с dwe внутри дистрибутива работает как Linux-случай.

«host daemon is not running for project …» — хостовый стек погашен или демон остановлен вручную. Запустите dwe run (или dwe bridge start) на хосте. С дефолтным on_unreachable: fail shim выходит с кодом 1 и хуки блокируют; задайте сервису on_unreachable: warn, чтобы превратить это в предупреждение + код 0 (например, для рекомендательных хуков).

«shim outdated, re-run dwe deploy to refresh shim binaries» — материализованный shim и хостовый dwe говорят на разных версиях протокола (обычно после обновления dwe при работающем стеке). dwe deploy run обновляет shim’ы и перезапускает демон, даже когда самому деплою нечего делать.

Bridged-команда зависает или падает только на нативном Linux — UFW/firewalld могут резать трафик, приходящий из docker-bridge-сети на IP шлюза. Unix-сокет-транспорт не затронут; если нужен TCP — разрешите input с docker-bridge-интерфейса.

Rootless Docker на Linuxhost-gateway там сломан, поэтому TCP недоступен; rootless-конфигурации покрывает unix-сокет-путь. Учтите: ремаппинг user namespace может сместить uid пира, который видит демон; если peercred-аутентификация не проходит, запускайте стек не-rootless или сверьтесь с трекером проекта о статусе token-фолбэка на unix-пути.

Bridged-команда выполняется из корня проекта, а не из текущей директории — shim не смог транслировать контейнерный cwd в хостовый путь (транслируется только маппинг dir / dir_internal сервиса), и демон откатился на корень проекта. Типичный триггер: git-хук или скрипт, который сделал cd за пределы контейнерного монтирования перед вызовом dwe (например, хостовая раскладка cd "$(git rev-parse --show-toplevel)/../../.." приводит в /). Команды продолжают работать — фолбэк фиксируется в dwe bridge logs. Хукам cd вообще не нужен: dwe находит проект, поднимаясь вверх из любой директории внутри него.

Базовый образ уже содержит /usr/local/bin/dwe — задайте bridge.shim_path на другой абсолютный путь, выигрывающий в PATH контейнера.

dwe в контейнере падает с «exec format error» сразу после первого старта — архитектура shim резолвится по существующему контейнеру, поэтому самый первый старт эмулируемого образа чужой архитектуры (например, amd64-only образ на arm64-маке) откатывается на архитектуру хоста и монтирует не тот shim. Запустите стек ещё раз: контейнер уже существует, архитектура резолвится по образу, и оверлей самовосстанавливается. Образы родной архитектуры не затронуты.

Куда смотретьdwe bridge status (живость демона, транспорты, состояние shim), dwe bridge logs (stderr демона, включая паники при старте) и dwe validate (домен bridge проверяет значения on_unreachable, абсолютность shim_path и маппинг воркспейса; dwe validate bridge ограничивает прогон этим доменом).