ctrllife.ru/ctrllife

DeepSeek Harness на сервере: установка, OpenRouter, systemd

DeepSeek Harness на сервере: установка, OpenRouter, systemd
Содержание15 разделов

DeepSeek Harness (dsh) — открытый каркас для агентов, построенный на Cordis по принципу «всё есть плагин»: модели, инструменты, навыки, сессии, песочницы, файловые системы и сам интерфейс собираются как подключаемые слои. Лицензия MIT, состояние — предварительный выпуск для разработчиков с заявленными ломающими изменениями.

Локальный запуск сводится к одной команде npx @deepseek-ai/dsh web. Задача этой статьи другая: постоянно работающий узел на удалённой машине, доступный по домену с сертификатом и паролем, с моделями через OpenRouter и службой systemd.

Каркас собирается из исходников. При предварительном выпуске это единственный способ получить состояние ветки main раньше публикации в реестре npm, закрепиться на конкретной ревизии и при необходимости внести собственные правки. Готовый пакет из npm отстаёт и правки не допускает.

Наружу узел публикуется через Nginx Proxy Manager: сертификат, принудительный HTTPS, поддержка веб-сокетов и список доступа с паролем настраиваются в веб-интерфейсе, а не в конфигурационных файлах.

Конфигурация проверена на Ubuntu 26.04 LTS. Команды рассчитаны на root-сессию, поэтому sudo в них не используется — за одним важным исключением: всё, что касается репозитория и сборки, выполняется от пользователя dsh через sudo -u dsh -H. Причина в разделе «Обновление без сюрпризов».

Что определяет схему установки

Четыре особенности каркаса задают всю дальнейшую конфигурацию. Пропуск любой из них приводит к типовым ошибкам, разобранным ниже.

Сервер слушает только петлю

Веб-приложение принимает --host, --port и повторяемый --trusted-host, но значение --host 0.0.0.0 умышленно не поддерживается и завершается ошибкой использования. По умолчанию адрес прослушивания — http://127.0.0.1:3080.

Отсюда главное следствие для публикации: Nginx Proxy Manager, работающий на другой машине, до этой петли не дотянется. На машине с dsh обязано слушать внешний адрес что-то своё — об этом шаг 7.

Токен входа не заменяет авторизацию

Интерфейс закрыт токеном, который печатается в журнал при запуске и меняется при каждом перезапуске. Без токена сервер отвечает 401 на любой запрос, включая curl с самой машины.

Однако это одноразовый пропуск, а не учётные записи и не разграничение прав: обладатель ссылки получает полный доступ и расходует средства на моделях. Список доступа в Nginx Proxy Manager обязателен.

Режим workspace-write ограничивает только запись

Новые сессии по умолчанию работают в режиме workspace-write: изменения через bash и файловую систему ограничены рабочим каталогом сессии и временными каталогами. Чтение, сетевой доступ и видимость процессов не ограничены.

Каталог запуска считается рабочим корнем

Во всех режимах рабочим корнем по умолчанию становится каталог запуска. Отсюда требование отдельного пустого каталога вместо домашнего каталога пользователя.

Итоговый план: отдельный системный пользователь с собственным префиксом npm, сборка из репозитория, служба systemd с защитными ключами, осознанно выданные права на Docker, переброска порта под межсетевым экраном, узел в Nginx Proxy Manager и только после этого — дополнения.

Шаг 1. Node.js и подготовка машины

Базовая установка среды выполнения из репозитория NodeSource:

apt update && apt install -y curl git build-essential
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
npm install -g pnpm@10
node -v && pnpm -v

Сразу после установки проверяется, какие именно исполняемые файлы оказались в PATH:

which -a node
command -v pnpm

Проверка не избыточна. Если на машине уже присутствовал Node, установленный не через apt (типичный случай — /usr/local/bin), после NodeSource в системе окажутся две версии: /usr/bin/node из пакета и другая, стоящая раньше в PATH. Команда node -v покажет вторую, сборка пойдёт на ней же, а pnpm установится рядом с ней — не в /usr/bin.

Записанный по привычке путь /usr/bin/pnpm в юните systemd даст ошибку запуска:

dsh.service: Unable to locate executable '/usr/bin/pnpm': No such file or directory

Пакет build-essential нужен зависимостям с нативной сборкой — они есть и у самого каркаса, и у части плагинов. Без него установка падает не на понятном сообщении о недостающем компиляторе, а на выводе node-gyp посреди списка пакетов.

Память под сборку

Сборка дерева TypeScript — самая требовательная к памяти часть установки. Порядок такой: сначала посмотреть, что есть, и только потом что-то добавлять.

free -h
swapon --show

Если свободно от 4 ГБ, делать нечего — сборка пройдёт как есть. Подкачка нужна только на небольших машинах, где памяти меньше и раздел подкачки отсутствует:

fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
free -h

Если сборка падает по куче

Отдельный случай — ошибка JavaScript heap out of memory при формально свободной памяти. Node сам выбирает потолок кучи, исходя из объёма машины, и на скромной конфигурации выбирает скромно: процесс упирается в предел раньше, чем в физическую память.

Внешний флаг для этого не нужен: сборочный сценарий репозитория сам запускает компилятор строкой node --max-old-space-size=4096, и переменная NODE_OPTIONS снаружи лишь продублирует уже заданное значение. Если четырёх гигабайт потолка не хватает физически, помогает подкачка, а не флаг. Текущий потолок при необходимости смотрится так:

node -e 'console.log(require("v8").getHeapStatistics().heap_size_limit / 1048576 + " MB")'

Шаг 2. Отдельный пользователь и каталоги

Служба работает не от root, хотя на шаге 6 этому пользователю выдаются полные права. Смысл здесь не в изоляции, а в порядке: у агента собственные каталоги, собственный префикс npm и понятная принадлежность файлов — в журналах и в ps сразу видно, где работает он, а где вы.

adduser --system --group --home /opt/dsh --shell /bin/bash dsh
mkdir -p /opt/dsh/work /opt/dsh/.dsh
chown -R dsh:dsh /opt/dsh

Оба каталога обязательны: work — рабочий корень сессий, .dsh — будущий $DSH_HOME для настроек, учётных данных и профилей. При создании только первого запись settings.yaml на шаге 4 завершится ошибкой No such file or directory.

Пользовательский префикс npm

Агенту требуется ставить пакеты. Установка в проект (npm install, pnpm add) прав root не требует вообще: зависимости попадают в node_modules рабочего каталога. Для глобальных установок задаётся пользовательский префикс, иначе они растекутся по системным каталогам и перемешаются с пакетами дистрибутива:

sudo -u dsh -H bash -lc '
mkdir -p /opt/dsh/.npm-global
npm config set prefix /opt/dsh/.npm-global
echo "export PATH=/opt/dsh/.npm-global/bin:\$PATH" >> /opt/dsh/.profile
npm config get prefix
'

Каталог /opt/dsh/.npm-global/bin обязательно попадает в PATH юнита на шаге 5, иначе служба не увидит программ, установленных агентом. Пакеты Python подключаются тем же способом — pip install --user или uv в домашнем каталоге.

Шаг 3. Сборка из исходников

Репозиторий клонируется в домашний каталог пользователя dsh, а не в рабочий корень сессий: смешивать исходники каркаса с каталогом, куда пишет агент, не стоит.

sudo -u dsh -H bash -lc '
cd /opt/dsh
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
'

Клон весит около 160 МБ, установка тянет примерно тысячу пакетов, среди которых есть увесистые вложенные программы: сборки Codex и Claude Agent SDK занимают по сотне мегабайт каждая. Установка укладывается в минуту, сборка — в несколько.

В выводе появляются предупреждения, на результат не влияющие: циклические зависимости рабочего пространства, неподдерживаемая платформа linux-arm64 у одного из вложенных пакетов и пара строк вида Failed to create bin … apps/cli/lib/bin.js. Последние выглядят пугающе, но объясняются просто: ссылка на исполняемый файл создаётся до сборки, а сам файл появляется после неё.

Сборка обязательна: рабочий запуск веб-приложения требует собранных пакетов и артефактов интерфейса, без них загрузка профиля падает на ошибках разрешения модулей. Проверка результата:

sudo -u dsh -H bash -lc 'cd /opt/dsh/deepseek-harness && pnpm dsh --version'

Как устроена сборка

Знание внутреннего устройства пригодится при обновлении, поэтому кратко: сценарий pnpm run build выполняет три этапа — build:lib:host, build:lib:client и build:web. Первые два сначала запускают tsc -b, который эмитирует каталог lib/ в каждом пакете рабочего пространства, а затем tsdown, который собирает пакеты, беря входом уже эмитированные файлы вида lib/types/index.js. Третий упаковывает интерфейс через Vite — в вывод попадает строка о числе клиентских артефактов, их пара сотен.

Отсюда два следствия. Каталоги lib/ — не исходники, а результат, и при обновлении они обязаны быть удалены. Список собираемых каталогов задан шаблонами в tsdown.config.ts (vendor/*, packages/*/*, apps/cli), а имя пакета берётся из ближайшего package.json — этот механизм даёт неочевидную ошибку, разобранную в разделе про обновление.

Установочные сценарии зависимостей

pnpm 10 и новее по умолчанию не выполняет сценарии install и postinstall у зависимостей — разрешение объявляется списком onlyBuiltDependencies в pnpm-workspace.yaml. В репозитории каркаса этот список уже заполнен, поэтому вмешательства не требуется: в выводе видны строки Running install script для node-pty и koffi и Running postinstall script для subprocess-local. Именно они собирают нативный модуль терминала, обвязку к системным библиотекам и вспомогательную программу запуска дочерних процессов.

Проверять нужно обратное — отсутствие этих строк и предупреждение об игнорированных сборках. Тогда инструменты, работающие через оболочку, откажут уже в сессии при формально успешной сборке. Разрешение выдаётся интерактивно и установка повторяется:

sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
pnpm approve-builds
pnpm install
'

Отдельный сюжет — падение установочного сценария у нативной зависимости. Выглядит как многострочный вывод node-gyp, заканчивающийся Completion callback never invoked! и выходом с кодом 6. Прежде чем искать причину в самой зависимости, стоит проверить свободное место, наличие build-essential и повторить установку начисто: перехваченные ошибки сети при разрешении дерева (строки ECONNRESET с повторными попытками) регулярно оставляют пакет в незавершённом состоянии, и вторая попытка проходит без единого замечания.

Закрепление ревизии

При предварительном выпуске main меняется ежедневно, и обновление вслепую однажды приходится на ломающее изменение. Рабочий узел закрепляется на конкретной ревизии, и хеш записывается в файл, а не в вывод терминала:

sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
git log --oneline -5
git rev-parse HEAD > /opt/dsh/last-good-rev
cat /opt/dsh/last-good-rev
'

По этому файлу выполняется откат, если очередное обновление окажется неудачным, и на него же ссылается сценарий из раздела про обновление. Ветка в качестве ориентира не годится — она движется.

Чем это отличается от npx и глобального пакета

README предлагает два пути: npx @deepseek-ai/dsh web и запуск из клона. Первый годится для разовой пробы на своей машине — версия не закреплена, кеш подтягивается из реестра, для постоянной службы это лишняя зависимость от доступности npm. Есть и третий, в README не описанный: npm install -g @deepseek-ai/dsh, поскольку в манифесте объявлено поле bin. Он проще сборки и обновляется одной командой, но даёт только опубликованную версию — на предварительном выпуске она заметно отстаёт от ветки и не допускает собственных правок.

Разрыв виден прямо в выводе сборки: пакеты рабочего пространства собираются под версией 0.1.2-alpha.4, тогда как в реестре на тот же день лежит 0.1.1-rc.2.

Сборка выбрана именно поэтому: свежий код, возможность закрепиться на ревизии, возможность править каркас. Плата — минуты ожидания и обязательный шаг сборки после каждого обновления.

Шаг 4. Модели через OpenRouter

OpenRouter не входит в готовый набор поставщиков. Штатно вводом ключа настраиваются DeepSeek, Anthropic и OpenAI; Bedrock, Vertex, Azure и Codex требуют родных учётных данных, и одного поля ключа для них недостаточно.

OpenRouter добавляется как пользовательский поставщик. Минимальный набор: идентификатор строчными буквами, базовый адрес, протокол, учётные данные и хотя бы одна модель.

Конфигурация через файл настроек

sudo -u dsh -H bash -c 'umask 077; cat > /opt/dsh/.dsh/settings.yaml <<EOF
llm-pi-ai:
  providers:
    openrouter:
      apiKeyEnv: OPENROUTER_API_KEY
      api: openai-completions
      baseURL: https://openrouter.ai/api/v1
      compat:
        supportsDeveloperRole: false
        maxTokensField: max_tokens
      models:
        - id: z-ai/glm-5.3-flash
          input: [text, image]
          contextWindow: 1000000
        - id: anthropic/claude-sonnet-4-5
          input: [text, image]
EOF'
sudo -u dsh -H bash -c 'umask 077; cat > /opt/dsh/.dsh-env <<EOF
OPENROUTER_API_KEY=sk-or-v1-ключ
DSH_HOME=/opt/dsh/.dsh
DSH_TELEMETRY_DISABLED=1
EOF'

Идентификаторы моделей указываются ровно как у OpenRouter, без приставки openrouter/. Приставка требуется там, где запрос идёт через LiteLLM; здесь обращение к совместимому с OpenAI адресу прямое, и лишний префикс даст UNKNOWN_MODEL.

Ключи compat. Именно с них рекомендуется начинать, если шлюз отвергает запросы при верных ключе и адресе. У модели, объявленной рассуждающей, системная подсказка уходит в роли developer, а ограничение вывода передаётся как max_completion_tokens, и не всякий совместимый шлюз это принимает.

Параметр input. Модель, введённая вручную, считается только текстовой, пока не указано иное: опросить адрес о поддерживаемых видах содержимого невозможно. Попытка приложить изображение к такой модели отклоняется до отправки запроса. Поэтому мультимодальным моделям вид содержимого объявляется явно: в примере выше input: [text, image] стоит у обеих.

Параметр contextWindow. Размер окна у добавленной вручную модели каркас не узнаёт: не объявленное явно значение подменяется консервативным дефолтом адаптера — 262 144 токена. Модель, принимающая миллион, работает вчетверо урезанной, и компакция контекста начинается задолго до настоящего предела. Поэтому размер объявляется рядом с остальными сведениями о модели — contextWindow: 1000000 в примере выше.

Число в API не передаётся: оно нужно самому каркасу — по нему планируются компакция и распознавание переполнения. Запрос уходит к шлюзу с любым объёмом как есть, поэтому завышенное значение опаснее заниженного: окно, заявленное больше реального предела шлюза, превращает переполнение из аккуратной компакции в ошибку API.

Порядок поиска учётных данных: унаследованное окружение → $DSH_HOME/.credentials.yaml → файл .env каталога запуска → $DSH_HOME/.env.

Конфигурация через интерфейс

Тот же результат достигается позже через Settings → Models → Add a custom provider: Provider ID openrouter, базовый адрес https://openrouter.ai/api/v1, протокол openai-completions, ключ и Fetch available models. Опрос идёт по совместимому с OpenAI GET /models, у OpenRouter такой метод присутствует.

Ключ записывается только в одну сторону: после сохранения страница получает замаскированное описание, а не исходное значение.

Идентификатор поставщика постоянен — на него ссылаются запросы, сохранённые сессии, модели по умолчанию и ссылки на учётные данные. Переименование не поддерживается, доступно только создание нового и удаление старого.

Что недоступно без ключа DeepSeek

Встроенный поиск использует DEEPSEEK_API_KEY и принимает DEEPSEEK_SEARCH_BASE_URL. Модели через OpenRouter работают, а инструмент web_search — нет. Инструмент web_fetch на текущих сборках работает без дополнительных правок — проверено на 0.1.2-alpha.4 с конфигурацией из этого шага.

Если поиск нужен, отдельный ключ DeepSeek только под эту функцию обходится дешевле полного перехода на родного поставщика.

Шаг 5. Служба systemd

Сначала фиксируются фактические пути, определённые на шаге 1 — их подставит heredoc без кавычек. Домен в --trusted-host указывается тот же, что будет заведён в Nginx Proxy Manager на шаге 7:

PNPM_BIN="$(command -v pnpm)"
NODE_DIR="$(dirname "$(command -v node)")"
echo "pnpm=$PNPM_BIN  node_dir=$NODE_DIR"
cat > /etc/systemd/system/dsh.service <<EOF
[Unit]
Description=DeepSeek Harness web
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=dsh
Group=dsh
WorkingDirectory=/opt/dsh/work
Environment=PATH=/opt/dsh/.npm-global/bin:$NODE_DIR:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/opt/dsh/.dsh-env
ExecStart=$PNPM_BIN --dir /opt/dsh/deepseek-harness dsh web --port 3080 --no-open --trusted-host dsh.example.com
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
KillSignal=SIGTERM

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/opt/dsh

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now dsh
sleep 25
systemctl status dsh --no-pager
curl -sI http://127.0.0.1:3080 | head -1

Ожидаемый ответ — HTTP/1.1 401: без токена каркас отказывает, и это признак работающей службы, а не ошибки.

Разбор ключевых директив

ExecStart через pnpm. Ключ --dir указывает на клон репозитория, поэтому рабочим каталогом службы остаётся /opt/dsh/work, а не каталог с исходниками. Путь к самому pnpm берётся из command -v, а не пишется по памяти: при двух установленных версиях Node он оказывается не в /usr/bin.

Обратная сторона запуска через pnpm. Перед выполнением команды pnpm сверяет состояние зависимостей с файлом блокировки и при расхождении сам вызывает pnpm install. Пока установка проходит, это незаметно. Но если она падает — например, обновление принесло зависимость, не собирающуюся на этой машине, — служба уходит в круговой перезапуск, и systemctl status при этом показывает active, потому что очередная попытка стартовала секунду назад. В журнале в таком случае видна не ошибка каркаса, а трассировка pnpm со строкой runDepsStatusCheck.

Проверить и отключить проверку можно ключом verify-deps-before-run — расположение настроек у pnpm менялось между версиями, поэтому сначала смотрится текущее значение:

sudo -u dsh -H bash -lc 'cd /opt/dsh/deepseek-harness && pnpm config get verify-deps-before-run'

Совсем убрать зависимость запуска от pnpm позволяет переход на собранный файл: ExecStart=$NODE_DIR/node /opt/dsh/deepseek-harness/apps/cli/lib/bin.js web --port 3080 …. Вариант требует обязательной успешной сборки перед каждым запуском и проверки на своей конфигурации, поэтому в статье основным оставлен запуск через pnpm — он предсказуемее при первой установке.

Environment=PATH — не косметика. У systemd собственный PATH, и без каталога Node запускающий сценарий pnpm со строкой #!/usr/bin/env node подхватит не ту версию, на которой выполнялась сборка. Первым в списке стоит /opt/dsh/.npm-global/bin из шага 2, иначе служба не найдёт программы, установленные агентом.

Защитные ключи (NoNewPrivileges, ProtectSystem, ProtectHome, ReadWritePaths) удерживают процесс службы в её каталоге. Первые два будут сняты на шаге 6 — без этого агент не сможет ставить системные пакеты; ReadWritePaths=/opt/dsh и остальные остаются, под ними находятся рабочий каталог сессий, исходники и префикс npm.

--trusted-host добавляет имена, принимаемые заслоном доверия браузера на /api. Без него интерфейс по домену открывается, но запросы к API отклоняются, и причина ошибочно ищется в настройках узла прокси. Флаг повторяемый: несколько доменов — несколько флагов.

Остановка. Дереву плагинов даётся до пяти секунд на разбор: первый SIGTERM начинает штатное завершение и выходит с кодом 0, второй сигнал завершает процесс немедленно. Значение TimeoutStopSec=15 не даёт systemd прервать завершение на середине.

Шаг 6. Полные права агенту

Этот шаг выполняется после создания службы: команды ниже перезапускают юнит и проверяют доступ уже от работающего процесса.

Зачем это нужно

Части задач одних npm-пакетов мало. Показательный пример — Playwright: сам пакет и браузеры ставятся в домашний каталог и проблем не вызывают, а вот playwright install-deps тянет через apt системные библиотеки, без которых Chromium не запускается. То же касается сборки нативных модулей и любых утилит из репозиториев.

Узкий список путей в sudoers для этого не годится. Установщик вызывает не apt-get напрямую, а оболочку с командной строкой внутри, и sudo отклоняет её сообщением sudo: I'm sorry dsh. I'm afraid I can't do that. Добавлять в список /bin/sh бессмысленно — это тот же полный доступ, только менее очевидный. Да и сам apt-get с ключом -o позволяет задать настройку вида APT::Update::Pre-Invoke с произвольной командой от root. Поэтому права выдаются прямо:

cat > /etc/sudoers.d/dsh <<'EOF'
dsh ALL=(ALL) NOPASSWD: ALL
EOF
chmod 440 /etc/sudoers.d/dsh
visudo -c
sudo -u dsh sudo -n id

Последняя команда должна вывести uid=0(root).

Защитные ключи юнита

Одного правила недостаточно: ключи юнита блокируют повышение прав раньше, чем sudo дойдёт до проверки. NoNewPrivileges=true завершает sudo сообщением The 'no new privileges' flag is set, а ProtectSystem=full оставляет /usr доступным только для чтения даже для root внутри службы. Оба удаляются:

sed -i '/^NoNewPrivileges=true$/d; /^ProtectSystem=full$/d' /etc/systemd/system/dsh.service
systemctl daemon-reload
systemctl restart dsh
systemctl show dsh -p NoNewPrivileges -p ProtectSystem
sudo -u dsh sudo -n apt-get update

Ожидаются NoNewPrivileges=no, пустой ProtectSystem и успешное обновление списка пакетов. Ключи PrivateTmp, ProtectHome и ReadWritePaths остаются: работе apt они не мешают, а порядок в каталогах поддерживают.

Отсюда требование к остальному периметру: раз внутренние границы сняты, значение приобретают внешние — список доступа перед портом, межсетевой экран, пропускающий только адрес прокси, и осознанный отбор дополнений из шага «Дополнения». Машину с такой настройкой следует считать выделенной под агента: соседство с чужими рабочими средами, ключами поставщиков моделей и токенами хостинга репозиториев означает, что цена одной неудачной подсказки — все они разом.

Проверка на Playwright

sudo -u dsh -H bash -lc '
cd /opt/dsh/work/проект
npm i -D @playwright/test
npx playwright install-deps chromium
npx playwright install chromium
npx playwright screenshot --browser chromium https://example.com /tmp/t.png
'

Браузер ложится в /opt/dsh/.cache/ms-playwright и занимает около двухсот мегабайт. Полученный снимок означает, что цепочка рабочая: агент поставил системные зависимости сам и запустил браузер.

Песочница: почему bash вообще отказывается выполняться

Режим workspace-write не декларация, а работающее ограничение: каждая команда bash запускается внутри песочницы файловой системы. Строится она средствами операционной системы — bubblewrap или Landlock на Linux, sandbox-exec на macOS, ограниченный маркер доступа на Windows. Если ни один механизм не доступен, каркас отказывается выполнять команду:

sandbox mode "workspace-write" is requested but no sandbox backend
is usable on this host; refusing to run the command unconfined

Отказ обманчив тем, что выглядит как проблема прав или настроек сессии, хотя относится к ядру. Инструменты чтения при этом продолжают работать — они идут другим слоем, — и агент честно сообщает, что читать может, а выполнять нет.

Проверка, чего именно не хватает:

sysctl kernel.unprivileged_userns_clone user.max_user_namespaces kernel.apparmor_restrict_unprivileged_userns
grep -i landlock /boot/config-$(uname -r)

Типовая причина на Ubuntu — единица в kernel.apparmor_restrict_unprivileged_userns. Landlock при этом собран в ядре и стоит первым в CONFIG_LSM, пространства имён не урезаны, но создать их непривилегированному процессу AppArmor не даёт, и bubblewrap не поднимается. Лечится так:

sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo 'kernel.apparmor_restrict_unprivileged_userns=0' > /etc/sysctl.d/99-userns.conf
apt install -y bubblewrap
sudo -u dsh bwrap --ro-bind / / --dev /dev true && echo 'песочница работает'

Файл в /etc/sysctl.d закрепляет настройку после перезагрузки. Затем служба перезапускается, чтобы каркас заново определил доступные механизмы.

Обратная сторона понятна из шага целиком: политика danger-full-access и подтверждения в положении never снимают песочницу вовсе. Вместе с полным sudo это означает, что любая команда из контекста выполняется немедленно и от root. Для узла, работающего без присмотра, песочницу стоит вернуть — она остаётся последним ограничителем.

Доступ к Docker

Отдельной настройки больше не требуется — с полным sudo агент добавит себя в группу сам. Если членство нужно сразу:

usermod -aG docker dsh
systemctl restart dsh
sudo -u dsh docker ps

Шаг 7. Публикация через Nginx Proxy Manager

Nginx Proxy Manager закрывает разом четыре задачи, которых у dsh нет: имя, сертификат Let's Encrypt с автоматическим продлением, принудительный HTTPS и пароль перед портом через список доступа. Настройка выполняется в веб-интерфейсе, файлы конфигурации nginx править не требуется.

Цепочка выглядит так: браузер → домен → узел в Nginx Proxy Manager → внешний адрес машины с dsh → переброска на 127.0.0.1:3080.

7.1. Переброска порта на машине с dsh

Роль переброски — отдать петлю наружу на одном высоком порту, закрытом межсетевым экраном для всех, кроме адреса прокси. Достаточно socat:

apt install -y socat

cat > /etc/systemd/system/dsh-forward.service <<'EOF'
[Unit]
Description=dsh port forward 0.0.0.0:3081 -> 127.0.0.1:3080
After=network-online.target dsh.service
Wants=network-online.target

[Service]
ExecStart=/usr/bin/socat TCP4-LISTEN:3081,fork,reuseaddr,bind=0.0.0.0 TCP4:127.0.0.1:3080
Restart=always
RestartSec=2
DynamicUser=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_INET AF_INET6

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now dsh-forward
ss -tlnp | grep 3081

Пока каркас перезапускается, socat принимает соединения и сразу их закрывает — снаружи это те же несколько секунд 502, что и при любой остановке службы. Разница в том, что после старта dsh всё восстанавливается само, без вмешательства.

Ключ DynamicUser=yes избавляет от запуска проброса от root: порт 3081 выше 1024, привилегий не требует. Если socat под ним почему-то не стартует, строка заменяется на User=nobody и Group=nogroup.

Если Nginx Proxy Manager работает на той же машине в контейнере, переброска не нужна вовсе — узел направляется на адрес мостовой сети Docker, а dsh остаётся на петле:

ip -4 addr show docker0

Полученный адрес (обычно 172.17.0.1) указывается в поле Forward Hostname / IP, порт — 3080. Значение 127.0.0.1 в этом поле не работает: внутри контейнера это его собственная петля.

7.2. Межсетевой экран и Fail2Ban

ufw allow proto tcp from АДРЕС_ПРОКСИ to any port 3081 comment 'dsh via npm'
ufw status numbered
sed -i 's|^ignoreip = .*|& АДРЕС_ПРОКСИ|' /etc/fail2ban/jail.local
fail2ban-client reload
grep ignoreip /etc/fail2ban/jail.local

Ключевая деталь: ignoreip предотвращает новые баны, но не снимает уже наложенный. Попавший в список адрес выпускается вручную:

fail2ban-client status ufw-scan
fail2ban-client set ufw-scan unbanip АДРЕС_ПРОКСИ

Ответ 1 означает снятый бан. Наличие десятков посторонних адресов в списке банов на машине хостера — норма, искать следует именно адрес прокси.

Когда таких адресов набираются сотни и половина из них — соседние адреса одной сети, разумнее закрыть сети целиком постоянным правилом, поставив его выше банов, и снять с Fail2Ban лишнюю работу:

ufw insert 1 deny from СЕТЬ_СКАНЕРА comment 'scanner net'

7.3. Узел в Nginx Proxy Manager

Домен направляется A-записью на адрес машины с прокси. Далее Hosts → Proxy Hosts → Add Proxy Host.

Вкладка Details:

Поле

Значение

Domain Names

dsh.example.com — ровно то имя, что задано в --trusted-host

Scheme

http — внутри цепочки шифрование не нужно

Forward Hostname / IP

внешний адрес машины с dsh (или адрес моста docker, если прокси на той же машине)

Forward Port

3081 при переброске, 3080 при работе через мост

Cache Assets

выключено — интерфейс отдаёт собственные заголовки

Block Common Exploits

включено, но снимается первым при разборе отказов на /api

Websockets Support

включено обязательно

Access List

созданный список с паролем

Вкладка SSL: Request a new SSL Certificate, включить Force SSL и HTTP/2 Support, указать почту, согласиться с условиями Let's Encrypt. HSTS включается по желанию и только после того, как домен заработал.

Вкладка Advanced. Ответы модели приходят потоком и держат соединение минутами, поэтому стандартных таймаутов nginx не хватает — длинный ответ обрывается на середине:

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 100m;

Отключённая буферизация здесь не тонкая настройка, а условие работы: с ней nginx копит ответ целиком и отдаёт его одним куском, из-за чего постепенное появление текста в интерфейсе пропадает.

7.4. Если заслон доверия всё равно отклоняет запросы

Бывает, что домен указан в --trusted-host, узел настроен верно, а интерфейс по-прежнему открывается пустым или отвечает отказом на /api. Тогда помогает приём, который стоит держать про запас: подменить заголовки так, будто запрос пришёл с самой машины.

location / {
    proxy_pass http://адрес_машины_dsh:3081;
    proxy_http_version 1.1;

    proxy_set_header Host 127.0.0.1:3080;
    proxy_set_header Origin http://127.0.0.1:3080;
    proxy_set_header Referer http://127.0.0.1:3080/;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Блок вставляется в то же поле Custom Nginx Configuration и заменяет набор директив из предыдущего подраздела: таймауты и отключённая буферизация уже внутри него, а заголовки веб-сокетов заданы вручную.

7.5. Список доступа — тот самый пароль перед портом

Access Lists → Add Access List: имя произвольное, на вкладке Authorization заводится пара имени и пароля, на вкладке Access — правило Allow для нужных адресов или 0.0.0.0/0, если вход должен работать отовсюду. Флажок Satisfy Any означает «достаточно любого из условий»; при пароле для всех адресов его следует снять.

Получается два независимых слоя: пароль списка доступа отсекает посторонних до того, как запрос дойдёт до каркаса, а токен dsh отсекает тех, кто пароль всё же узнал. Ни один из них не заменяет другой: список доступа не различает пользователей внутри интерфейса, а токен общий и живёт до перезапуска.

Когда домен не требуется

Для разовой работы с одной машины вся эта цепочка заменяется одной командой:

ssh -N -L 3080:127.0.0.1:3080 пользователь@сервер

Интерфейс открывается по http://127.0.0.1:3080, петля считается защищённым контекстом, и Web Crypto API работает без сертификата. Отпадают узел, правило ufw и оговорки про Fail2Ban. Плата — доступ только оттуда, где есть ключ SSH: ни с телефона, ни из чужого браузера войти не получится. Именно поэтому основной схемой остаётся Nginx Proxy Manager.

Шаг 8. Вход по токену и диагностика по кодам ответа

Собственная проверка входа у веб-интерфейса есть: без токена сервер отвечает 401 Unauthorized на любой запрос, включая curl с самой машины. Ссылка с токеном печатается в журнал при запуске:

dsh web: http://127.0.0.1:3080/?token=<TOKEN>

Открывается собственный домен с тем же параметром:

https://dsh.example.com/?token=<TOKEN>

Токен меняется при каждом перезапуске службы и в $DSH_HOME не сохраняется. Прежняя вкладка после перезапуска выглядит рабочей, но зависает на надписях вида «Loading history…» и «Loading models…»: страница отрисована, а постоянное соединение открывается заново и получает отказ по старому пропуску. Лечится входом по свежей ссылке с полным обновлением страницы.

При поиске токена в файлах следует учитывать, что grep -r token /opt/dsh/.dsh даёт ложное совпадение с settings.yaml: слово встречается в строке maxTokensField: max_tokens.

Для повседневной работы удобен помощник:

cat > /usr/local/bin/dsh-url <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
t=$(journalctl -u dsh -n 200 --no-pager | grep -o 'token=[A-Za-z0-9_-]*' | tail -1)
[ -n "$t" ] || { echo "токен в журнале не найден — служба ещё поднимается?" >&2; exit 1; }
echo "https://dsh.example.com/?$t"
EOF
chmod 755 /usr/local/bin/dsh-url
dsh-url

Коды ответа указывают на слой обрыва

Наблюдаемый результат

Место обрыва

401 от curl на петле

штатное поведение: dsh запрашивает токен

303 при запросе с ?token=

штатное поведение: токен принят, выдана cookie, идёт перенаправление на /

Окно с запросом пароля браузера

список доступа работает, цепочка до nginx цела

401 после ввода пароля

цепочка цела целиком: нужен параметр ?token=

502 от Nginx Proxy Manager или Cloudflare

источник недостижим: не работает переброска, ufw или Fail2Ban

Страница открылась, ответ не приходит

не включена поддержка веб-сокетов

Страница открылась, висит «Loading history…»

вкладка со старым токеном после перезапуска службы

Ответ обрывается на середине

таймауты и буферизация на вкладке Advanced

Запросы к /api отклонены

домен не указан в --trusted-host либо мешает Block Common Exploits

Connection refused с машины прокси

не работает dsh-forward.service

Таймаут с машины прокси

межсетевой экран

Проверка выполняется с машины прокси, а не с той, где установлен dsh:

curl -sv --max-time 10 http://адрес_машины_dsh:3081 2>&1 | tail -20

Ответ 401 на этом шаге — хороший знак: он означает, что до каркаса запрос дошёл и разбираться дальше нужно уже в настройках узла.

Шаг 9. Первая настройка интерфейса

  1. Settings → Models — поставщик openrouter должен присутствовать в списке. Выбранная модель становится моделью по умолчанию для новых сессий.
  2. Choose workspace — добавляется и выбирается /opt/dsh/work. До выбора рабочего каталога поле ввода сессии недоступно.
  3. Пробная задача для проверки цепочки целиком.

Дополнения: как выбирать и в каком порядке ставить

Экосистема выглядит внушительно: по метке dsh-plugin на GitHub тысячи репозиториев, основной сводный список после чистки накруток насчитывает около трёх тысяч плагинов, сводных списков конкурирует около пяти, десятки новых проектов выходят еженедельно. При этом большинство проектов моложе месяца, у значительной части менее сотни звёзд, а сам каркас меняется под ними.

Один из сводных списков прямо указывает, что ежедневный отбор ограничивается статической проверкой сценариев, зависимостей, точек входа и чувствительных операций — это не полный аудит и не гарантия совместимости.

Механика установки

Плагины ставятся не в каталог с исходниками, а в профиль внутри $DSH_HOME. Команда передаёт аргументы в pnpm с каталогом профиля в качестве рабочего, поэтому add, remove и update работают как обычно:

sudo -u dsh -H bash -lc 'DSH_HOME=/opt/dsh/.dsh pnpm --dir /opt/dsh/deepseek-harness dsh plugin --profile web add ПАКЕТ'
systemctl restart dsh

После каждого успешного запуска состав слоёв сверяется с установленным: зависимость, чей манифест объявляет ключ dsh.bundle.patch, входит в стек слоёв, зависимость без такого объявления остаётся обычной с однократным предупреждением, удалённая — покидает стек.

Две типовые ловушки:

  • Сборка при установке. Плагины из git, собирающиеся собственным сценарием prepare, блокируются pnpm 10. Первый add падает с подсказкой про allowBuilds и указанием на pnpm-workspace.yaml профиля — напечатанный ключ копируется туда, после чего команда повторяется.
  • Конфликт слоёв. Команда dsh plugin add добавляет в слой пакетов все зависимости профиля, объявившие dsh.bundle, и при пересечении с уже установленным каркас падает при запуске с duplicate loader entry id.

Отсюда рабочее правило: по одному пакету за раз, с перезапуском службы и проверкой журнала между установками.

Минимальный набор для серверной установки

Проверка безопасности. dsh-security-audit работает только на чтение: настройки, происхождение плагинов, сессии, сетевая открытость. В реестре npm его нет — ставится прямо из репозитория, github:omdsh-dev/dsh-security-audit: одноимённые проекты-двойники на GitHub существуют, поэтому источник указывается явно. Ставится первым и запускается заново после каждого следующего дополнения.

Политика подтверждений. dsh-auto-mode (отказ по умолчанию, проверки защищённых путей и учётных данных) либо dsh-tool-approval (ручное подтверждение вызовов). Это прямое закрытие исходного ограничения: по умолчанию агент читает произвольные файлы и обращается в сеть без подтверждения.

Состав контекста. dsh-context показывает, из чего набрано контекстное окно и как оно меняется. При работе через OpenRouter это единственный вменяемый способ контролировать расход: счётчики баланса (dsh-cost-meter, dsh-billing, dsh-balance-meter) привязаны к счёту DeepSeek и покажут пустоту. Фактические расходы отслеживаются на странице активности OpenRouter.

Клиент MCP входит в поставку как зависимость для слоёв правок, но ни один сервер не включён по умолчанию: команда каждого сервера — доверенный исполняемый код за пределами песочницы агента. При наличии собственной автоматизации это самая ценная связка, но включается она осознанно, слоем правок.

Почему dsh-passwords здесь не нужен

Плагин dsh-passwords добавляет каркасу собственный заслон с входом, подучётными записями, ограничениями по токенам и времени и автоматическим сертификатом. При публикации через Nginx Proxy Manager большая часть этого дублируется, а сертификат прямо конфликтует со схемой: плагин занимает порты 80 и 443 и тянет собственный Let's Encrypt, тогда как TLS уже завершается на прокси. Обход требует MCP_GATEWAY_AUTO_TLS=0 и высокого порта.

Плагин оправдан при нескольких пользователях с разными квотами — список доступа nginx такого не умеет. Для одного оператора пара «список доступа плюс токен» закрывает задачу меньшими средствами.

Что на сервере не нужно

Терминальные интерфейсы, пусковые программы для Windows, настольные клиенты, управление браузером и компьютером (требуют рабочего стола), оформление и мини-игры. Плагин dsh-ssh (удалённое выполнение, SFTP, ProxyJump) выглядит привлекательно при парке машин, но фактически означает выдачу агенту ключей от остальных серверов — до настроенной и проверенной политики подтверждений его установка откладывается.

Рекомендуемый порядок

Проверка безопасности → политика подтверждений → проверка на живой сессии → панель контекста → MCP. Каждое дополнение закрепляется на версии, а не на ветке: при предварительном выпуске обновление плагина и обновление каркаса регулярно конфликтуют.

Обновление без сюрпризов

Обновление — это git, установка зависимостей и обязательная пересборка. Формально три команды, на практике — четыре подводных камня, каждый из которых даёт ошибку, выглядящую как поломка ветки, хотя ветка исправна.

Что ломается и почему

Артефакты прошлой сборки. Каталоги lib/ генерируются компилятором и входом для tsdown служат именно они. После git pull в дереве оказываются свежие исходники и старые артефакты. Если обновление изменило состав экспортов, сборка падает так:

[MISSING_EXPORT] "PersistenceCoordinator" is not exported by "../session-persistence/src/index.ts".
  [ lib/types/index.js:8:107 ]

Ключ к разгадке — путь в квадратных скобках: импорт идёт из lib/, то есть из результата предыдущей сборки, а не из src/. Лечится удалением артефактов, а не откатом.

Осиротевшие каталоги. Когда пакет удаляют выше по течению, git pull убирает его отслеживаемые файлы, но каталог node_modules внутри остаётся — он не отслеживается и подпадает под правила игнорирования. Каталог продолжает существовать, попадает под шаблон packages/*/* из tsdown.config.ts, а имя пакета для него берётся из ближайшего package.json — то есть из корневого:

ERROR  Error: [@deepseek-ai/dsh-root] Cannot find entry: ["lib/types/{index,invariant,startup}.js"]

Опознаётся по тому, что git status при этом чистый: дерево ровно соответствует ревизии, лишнее лежит вне поля зрения git. Именно поэтому очистка артефактов командой git clean -xdf -e node_modules сама по себе недостаточна — исключение node_modules и сохраняет эти оболочки.

Владелец каталога. Все команды по репозиторию выполняются от пользователя dsh. От root git отвечает fatal: detected dubious ownership, и молча не выполняется не только очистка, но и сборка — сценарий клиента вызывает git rev-parse HEAD и падает с Command failed.

Отсутствие предохранителей. Без set -e внутри bash -lc падение установки не останавливает цепочку: следом выполняется сборка, а после — запуск службы на неполном дереве. И поскольку ExecStart идёт через pnpm, служба уходит в круговой перезапуск, отображаясь при этом как active.

Сценарий обновления

Всё перечисленное закрывается одним файлом. Сохраняется как /root/dsh-update.sh с правами 750:

#!/usr/bin/env bash
set -Eeuo pipefail

REPO=/opt/dsh/deepseek-harness
STATE=/opt/dsh/last-good-rev
RUNAS="sudo -u dsh -H"

PREV=$($RUNAS git -C "$REPO" rev-parse HEAD)
echo "текущая ревизия: $PREV"

if ! $RUNAS git -C "$REPO" diff --quiet || ! $RUNAS git -C "$REPO" diff --cached --quiet; then
  echo "в рабочем дереве есть незафиксированные правки — остановка"
  exit 1
fi

systemctl stop dsh

update_and_build() {
  local rev="$1"
  $RUNAS bash -lc "
    set -Eeuo pipefail
    cd '$REPO'
    if [ '$rev' = 'pull' ]; then
      git fetch --all --tags
      git pull --ff-only
    else
      git checkout --force '$rev'
    fi
    echo \"ревизия после шага: \$(git rev-parse HEAD)\"

    # артефакты прошлой сборки
    git clean -xdf -e node_modules

    # оболочки удалённых выше по течению пакетов: node_modules без package.json
    for d in vendor/*/ packages/*/*/ apps/*/; do
      if [ -d \"\${d}node_modules\" ] && [ ! -f \"\${d}package.json\" ]; then
        echo \"удаляю осиротевший каталог: \$d\"
        rm -rf \"\$d\"
      fi
    done

    pnpm install --frozen-lockfile
    pnpm run build
  "
}

if update_and_build pull; then
  echo "обновление и сборка прошли"
  $RUNAS bash -lc "git -C '$REPO' rev-parse HEAD > '$STATE'"
else
  echo "обновление не удалось, откатываюсь на $PREV"
  update_and_build "$PREV"
  echo "откат выполнен"
fi

systemctl start dsh

ok=0
for i in $(seq 1 90); do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:3080/ || true)
  if [ "$code" = "200" ] || [ "$code" = "401" ] || [ "$code" = "303" ]; then
    echo "порт 3080 отвечает (код $code, попытка $i)"
    ok=1
    break
  fi
  sleep 2
done

if [ "$ok" -ne 1 ]; then
  echo "порт 3080 не ответил за 180 секунд"
  journalctl -u dsh -n 80 --no-pager
  exit 1
fi

echo "состояние: $(systemctl is-active dsh), перезапусков: $(systemctl show dsh -p NRestarts --value)"
echo "проброс: $(systemctl is-active dsh-forward)"
echo "точка отката: $(cat "$STATE")"
journalctl -u dsh -n 100 --no-pager | grep -o 'http://127.0.0.1:3080/?token=[^ ]*' | tail -1

Что здесь важно по пунктам:

  • Ревизия читается до остановки службы и записывается в файл, а не в вывод терминала. Иначе после закрытия вкладки откатываться некуда.
  • Проверка чистоты дерева отсекает случай с плагинами, правящими файлы каркаса накладкой.
  • Функция update_and_build одна и та же для обновления и для отката — значит откат проходит ровно ту же очистку и не наследует артефакты неудачной ревизии.
  • Точка отката обновляется только после успешной сборки.
  • Проверка результата — отклик порта и счётчик перезапусков, а не systemctl status: в круговом перезапуске статус всегда бодрый.
  • Код 303 в списке допустимых — на случай, если проверка попадёт на адрес с параметром токена.

Обновляться по тегам, а не по ветке

Ветка main предварительного выпуска ломается регулярно, и часть поломок приходится не на код, а на сборочную обвязку. Если в репозитории есть теги выпусков, разумнее двигаться по ним:

sudo -u dsh -H bash -lc 'cd /opt/dsh/deepseek-harness && git fetch --tags && git tag --sort=-creatordate | head -10'

Тег подставляется в сценарий вместо pull — функция update_and_build принимает любой указатель, понятный git checkout.

Когда достаточно пакета из npm

Если правки в каркас не вносятся и отставание опубликованной версии от ветки не мешает, сборку можно заменить установкой npm install -g @deepseek-ai/dsh в тот же префикс из шага 2. Тогда отпадают подкачка, минуты ожидания, риск забыть пересборку и весь разбор из этого раздела — обновление сводится к одной команде с точным номером версии. Шаги 2 и 5–9 при этом не меняются, кроме строки ExecStart, где вместо pnpm указывается /opt/dsh/.npm-global/bin/dsh.

Сводная таблица ошибок

Симптом

Причина

Установлена посторонняя программа

выполнено apt install dsh; каркас ставится только из репозитория или npm

Unable to locate executable '/usr/bin/pnpm'

pnpm установлен рядом с другим Node; путь берётся из command -v pnpm

JavaScript heap out of memory

памяти меньше, чем требует сборка: добавляется подкачка (потолок кучи сборочный сценарий задаёт сам)

node-gyp … Completion callback never invoked!

прерванная сборка нативной зависимости: проверяются место на диске и build-essential, установка повторяется начисто

«Обновление выполнено, изменений нет»

пропущен pnpm run build после git pull

Ошибки разрешения модулей при старте

артефакты не собраны: pnpm run build не выполнялся вовсе

MISSING_EXPORT … is not exported by ../<пакет>/src/index.ts

сборка идёт по артефактам lib/ от прошлой ревизии; нужен git clean -xdf -e node_modules

Cannot find entry: ["lib/types/{index,invariant,startup}.js"] у dsh-root

осиротевший каталог удалённого выше по течению пакета: остался node_modules без package.json, tsdown берёт имя из корневого манифеста

fatal: detected dubious ownership

команды git выполняются от root в каталоге пользователя dsh; всё делается через sudo -u dsh

Command failed: git rev-parse HEAD при сборке

та же причина: сценарий клиента вшивает ревизию и упирается в проверку владельца

Служба в круговом перезапуске, статус active

ExecStart через pnpm: перед запуском команды сверяется состояние зависимостей и вызывается pnpm install

Предупреждение об игнорированных сборках

сценарии зависимостей пропущены; нужен pnpm approve-builds и повторный pnpm install

Инструменты с оболочкой отказывают в сессии

не собраны node-pty и koffi: установка прошла без установочных сценариев

Пакеты встали не туда, куда ожидалось

вызов sudo -u dsh без ключа -H: прочитана настройка root

EACCES при npm install -g от агента

не задан пользовательский prefix для npm

Программа, установленная агентом, не найдена

/opt/dsh/.npm-global/bin отсутствует в PATH юнита или PATH дописан в .bashrc вместо .profile

Не к чему откатиться после неудачного обновления

ревизия не записана в файл до git pull; ветка ориентиром не служит

[Unit]: command not found

содержимое юнита вставлено в оболочку; требуется cat > файл <<'EOF'

settings.yaml: No such file or directory

не создан $DSH_HOME (/opt/dsh/.dsh)

--host 0.0.0.0 завершается ошибкой

не поддерживается умышленно; нужна переброска порта

Узел заведён, но отдаёт 502

на машине с dsh ничего не слушает внешний адрес: не запущен dsh-forward.service

502 сразу после успешного обновления

в юните проброса стоит Requires=dsh.service: служба остановлена вместе с каркасом и обратно не поднялась

502 при прокси на той же машине

в поле Forward указан 127.0.0.1 вместо адреса моста docker

502 при работающей службе и открытом порте

Fail2Ban забанил адрес прокси: нужны ignoreip и unbanip

Разрешающее правило ufw исчезло

удаление по номеру после смещения нумерации истёкшими банами

Страница открылась, ответ модели не приходит

в узле не включена Websockets Support

Страница открылась, висит «Loading history…»

вкладка со старым токеном после перезапуска службы; нужен вход по свежей ссылке с полным обновлением

Длинный ответ обрывается

стандартные таймауты nginx; задаются на вкладке Advanced

Текст появляется одним куском в конце

включена буферизация; отключается proxy_buffering off

Интерфейс открывается, запросы к /api отклоняются

домен не задан в --trusted-host либо мешает Block Common Exploits; при упорстве — подмена Host/Origin/Referer из 7.4

Пароль списка доступа не спрашивается

собственный location / перекрывает блок, создаваемый Nginx Proxy Manager

crypto.randomUUID is not a function

вход по http: не включён Force SSL

UNKNOWN_MODEL на OpenRouter

лишняя приставка openrouter/ в идентификаторе модели

Изображение не прикрепляется к модели

у модели, добавленной вручную, не объявлен input: без него модель считается только текстовой; см. шаг 4

Компакция срабатывает слишком рано, окно урезано

у модели не указан contextWindow: адаптер подставляет дефолт 262 144 вместо заявленного производителем размера; см. шаг 4

Шлюз отвергает все запросы при верном ключе

требуются compat.supportsDeveloperRole: false и maxTokensField: max_tokens

Правка модели не применилась

старая сессия держит модель из своего журнала; начинается новая

401 на любой запрос, включая петлю

штатное поведение: требуется ?token= из журнала запуска

303 при входе с ?token=

штатное поведение: выдаётся cookie dsh-auth-… и перенаправление на /; для curl нужен ключ -L

000 при проверке веб-сокета через curl

свойство curl: он не говорит по этому протоколу и обрывает соединение; проверять в средствах разработчика браузера

Токен не подходит после перезапуска

токен новый; извлекается из журнала заново

dsh plugin add не выполняется

в PATH пользователя нет pnpm

Первый add плагина из git падает

pnpm 10 блокирует сборку; ключ из подсказки вносится в pnpm-workspace.yaml профиля

duplicate loader entry id при запуске

пересечение слоёв после dsh plugin add

Плагин не ставится через домен

управление плагинами работает только локально

Правки плагина пропали после обновления

накладка на файлы каркаса стёрта заменой каталога модуля

sudo: The 'no new privileges' flag is set

из юнита не удалены NoNewPrivileges и ProtectSystem; см. шаг 6

no sandbox backend is usable on this host

единица в kernel.apparmor_restrict_unprivileged_userns; bubblewrap не поднимается

sudo: I'm sorry … I can't do that при install-deps

установщик вызывает оболочку, а не apt-get; узкий список путей в sudoers для этого не годится

Файл из /tmp не виден в контейнере

PrivateTmp=true: у службы собственный /tmp

Безопасность и телеметрия

Перед запуском рекомендуется прочитать SAFETY.md в репозитории — на этом разработчики настаивают в описании проекта.

Телеметрия по умолчанию остаётся локальной. При её включении в выгрузку попадают тексты сообщений, аргументы и результаты вызовов инструментов и пути рабочего каталога; правил вычистки чувствительных данных в поставке нет. Любое непустое значение DSH_TELEMETRY_DISABLED означает окончательный отказ от выгрузки.

Заключение

Сборка из исходников плюс Nginx Proxy Manager дают узел, который работает на свежем коде и при этом остаётся управляемым: ревизия закреплена и записана в файл, откат выполняется тем же сценарием, что и обновление, а имя, сертификат, принудительный HTTPS и пароль перед портом настраиваются в браузере и переживают перезапуски без правки конфигурационных файлов.

Тонкие места сосредоточены в трёх точках. Первая — последнее звено цепочки: прокси на соседней машине до петли не дотянется, поэтому переброска порта, правило ufw и адрес прокси в ignoreip обязательны, иначе 502 при полностью исправной службе. И проброс не должен зависеть от каркаса через Requires, иначе первое же обновление оставит домен без источника.

Вторая — три настройки узла, без которых интерфейс выглядит сломанным при рабочем соединении: поддержка веб-сокетов, Force SSL и таймауты с отключённой буферизацией.

Третья — дисциплина обновления, и она обходится дороже всего. git pull без pnpm run build тихо оставляет старый интерфейс. Пересборка поверх старых артефактов lib/ падает с ошибками, указывающими на исходники, хотя виноват результат прошлого запуска. Осиротевшие каталоги удалённых пакетов ломают сборку от имени корневого манифеста. А ревизия, не записанная заранее, лишает возможности вернуться. Один сценарий из соответствующего раздела закрывает всё это разом — и именно его стоит завести раньше, чем понадобится.

Всё остальное — разграничение прав. Агент по умолчанию читает произвольные файлы и обращается в сеть, поэтому отдельный системный пользователь, защитные ключи юнита и осознанный выбор схемы доступа к Docker дают больше, чем любой набор дополнений.

Оцените запись