dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
In-chat preview modal for local files, images, PDFs, Office documents and website links in the DeepSeek Harness Web UI
Плагин DSH, который открывает файлы, картинки, PDF, документы Office и ссылки прямо в чате: по клику на путь или ссылку поверх переписки появляется аккуратное модальное окно с предпросмотром и действиями «скопировать», «открыть в браузере», «показать в проводнике», «открыть в приложении по умолчанию».
Плагин состоит из двух половин:
index.js + lib/host/) — регистрирует HTTP-маршруты
/api/dsh-file-preview/* на webServer DSH, читает файлы с диска,
конвертирует Office в PDF, тянет метаданные ссылок и выполняет системные
действия;lib/client.js) — перехватывает клики в чате, рисует модалку,
копирует текст через clipboard браузера.Пакетный скелет и host-половина собраны в задаче H1; клиентская половина
написана параллельной задачей client-dev и уже лежит в lib/client.js.
Манифест ссылается на неё через exports["./client"].
ELECTRON_RUN_AS_NODE=1: require('electron')
падает с MODULE_NOT_FOUND, а ESM-неймспейс пуст (нет shell и clipboard).
Поэтому открытие/показ и файловый буфер выполняются запуском системных
команд (как в dsh-host-open-in-app); текст/картинку в буфер копирует клиент.node:* и относительные импорты. Клиентский бандл собирает client-dev.$DSH_HOME/cache/file-preview/ (кэш PDF).База: /api/dsh-file-preview. Все маршруты отвечают только loopback-клиенту
(см. «Замок безопасности»).
| Метод и путь | Что отдаёт |
|---|---|
GET /meta?path=&cwd= | {ok:true,path,absPath,name,ext,kind,mime,size,mtimeMs,preview,officeExt?,canRead,reason?}. preview: image|pdf|text|markdown|code|html|office|video|audio|none. Ошибки: 400 bad-path, 403 outside-allowed-roots, 404 not-found. |
GET /raw?path=&download=0|1 | байты файла; Range → 206 + content-range, иначе 200; content-type, content-length, accept-ranges, content-disposition: inline/attachment; filename*=UTF-8''…, cache-control: private, max-age=30, x-content-type-options: nosniff. |
GET /text?path=&maxBytes= | {ok:true,text,truncated,encoding,bytes}; бинарное содержимое — {ok:false,code:'binary'} с HTTP 200. UTF-8, при «битых» байтах — fallback Windows-1251. |
GET /office-pdf?path= | application/pdf через сервис officeToPdf; не-Office расширение — 415 {code:'not-office'}, сервис не смонтирован — 501 {code:'office-unavailable'}. |
GET /link?url=<http(s)> | {ok:true,url,host,title?,description?,siteName?,icon?,image?,error?}; таймаут 5 с, не больше 512 КиБ тела, http(s) только, не бросает исключений. |
GET /resolve?name=<basename>&cwd= | {ok:true,path,absPath,name,size,mtimeMs} — совпадение по имени в $DSH_HOME/attachments (приоритетный корень), иначе в cwd; самый свежий по mtimeMs внутри выбранного корня; 404 not-found или 409 ambiguous (несколько одинаково свежих). |
POST /action | тело {action, path?|url?}, action ∈ open|reveal|browser|copy-file → {ok:true} либо {ok:false,code,message}. |
GET /status | {ok:true,version,startedAt,requests:{meta,raw,text,office,link,action,resolve},lastError,lastClientSeat}. |
POST /action требует content-type: application/json; иначе 415.
Текстовые/картиночные «копировать» действия в хосте отсутствуют: этот clipboard
целиком на клиенте. Отдельный copy-file кладёт сам файл на системный
clipboard.
/action)| Действие | Windows | macOS | Linux |
|---|---|---|---|
open | explorer.exe <file URI> | open <path> | xdg-open <path> |
reveal | explorer.exe /select,<file URI> | open -R <path> | xdg-open <dir> |
browser | rundll32.exe url.dll,FileProtocolHandler <url> | open <url> | xdg-open <url> |
copy-file | фиксированный PowerShell-скрипт с System.Windows.Forms.Clipboard.SetFileDropList (STA) | фиксированный osascript с POSIX file reference | 501 action-unavailable |
copy-file передаёт путь отдельным argv-элементом, а не вставляет его в
текст скрипта; лаунчер запускается без shell, ждёт завершения с коротким
settle-таймаутом (3 с) и по коду выхода отвечает {ok:true} либо
{ok:false, code:'copy-file-failed', message} с кодом в сообщении. На Windows
файл попадает в clipboard как file-drop list (вставляется в Explorer и почтовые
клиенты); на macOS — как POSIX file reference. На Linux портируемого
file-drop clipboard нет, поэтому платформа честно отвечает action-unavailable
(текстовый clipboard клиента при этом работает).
/resolve — зачем он нуженКарточка пользовательского вложения в DOM содержит только отображаемое имя
файла: ни пути, ни attachment id (см. .research/file-preview/findings.md §3c,
§4.2). Поэтому по имени ищется файл с явным приоритетом корней:
$DSH_HOME/attachments — хранилище вложений. Если там есть хотя бы одно
совпадение по basename, ответ берётся только из этого корня: самый свежий
по mtimeMs. Более новый одноимённый файл в workspace на результат не влияет —
пользователь кликнул именно вложение.cwd запроса — запасной корень. Сканируется только если в
$DSH_HOME/attachments совпадений нет; выбор и правило ничьей те же.Поиск ограничен глубиной (resolveMaxDepth) и общим количеством просмотренных
записей (resolveMaxEntries); символические ссылки пропускаются. Если внутри
выбранного корня несколько файлов имеют одинаковый самый свежий mtimeMs,
маршрут честно отвечает 409 ambiguous с кандидатами, а не выбирает случайный.
Каждый маршрут проходит одну проверку:
req.socket.remoteAddress — loopback (127/8, ::1, IPv4-mapped 127/8);Host — loopback-хост (localhost, 127/8, [::1]) — защита от
DNS rebinding;sec-fetch-site, если он есть, — только same-origin, same-site или
none;Origin, если он есть, совпадает с Host;POST — application/json.Кросс-сайтовый запрос получает 403 с JSON {ok:false,code:'forbidden'}.
Если в композиции смонтирован сервис connection, дополнительно вызывается
connection.requestRejection(req) — как в dsh-host-open-in-app.
Тела POST ограничены 64 КиБ; JSON-ответы уходят с cache-control: no-store и
x-content-type-options: nosniff.
Защита (не зависит от readScope): плагин не отдаёт файлы удалённым страницам,
не поддаётся DNS rebinding (проверка Host), отклоняет кросс-сайтовые запросы
(sec-fetch-site/Origin), не пускает неаутентифицированного вызывающего
(loopback + connection.requestRejection, если сервис смонтирован), требует
JSON для POST; вызывающего он не перенаправляет (редиректы источника для
/link остаются в пределах http(s)).
При этом в desktop-сценарии это страница самого владельца: по умолчанию
(readScope: any) его клик по ссылке в чате должен открывать предпросмотр
любого существующего файла на этой машине — в том числе вне cwd сессии,
$DSH_HOME, домашнего каталога и temp (D:\Work\..., подключённый диск,
существующий UNC-путь). Это осознанное разрешение, а не дыра: запрос всё равно
приходит только из loopback-окна DSH после проверок выше.
Строгий режим включается явно:
config:
readScope: roots
allowRoots: ['D:/projects', '\\\\server\\share']
readScope: roots возвращает allow-list: реальный путь обязан лежать внутри
cwd запроса, $DSH_HOME, os.homedir(), os.tmpdir() или allowRoots;
иначе — 403 {code:'outside-allowed-roots'}.
Вход path может быть:
cwd из query, а
без него — от process.cwd() Host-процесса;~ в начале (~/file.txt);"file.txt");file://-URL (file:///C:/dir/file.txt).По умолчанию (readScope: any) читающие маршруты (/meta, /raw, /text,
/office-pdf, /resolve) принимают любой существующий путь; отсутствующий
файл по-прежнему даёт 404 not-found. Символические ссылки разрешаются через
realpath, поэтому в строгом режиме ссылка не может вывести за корень.
В режиме readScope: roots включается прежний allow-list — реальный путь
обязан лежать внутри одного из корней:
cwd запроса;$DSH_HOME (по умолчанию ~/.dsh);os.homedir();os.tmpdir();Config.allowRoots.Перед сравнением путь проходит realpath (для ещё не существующего файла
разрешается ближайший существующий предок). На Windows сравнение
регистронезависимое. Управляющие символы и NUL в пути отклоняются с 400 bad-path в обоих режимах. /action пользовательский и всегда принимает любой
существующий путь без проверки корней.
config у строки file-preview)- insert:
- id: file-preview
name: 'dsh-file-preview'
config:
readScope: any
allowRoots: ['D:/projects', '\\\\server\\share']
officeTimeoutMs: 120000
copyFileTimeoutMs: 3000
linkTimeoutMs: 5000
linkMaxBytes: 524288
resolveMaxDepth: 8
resolveMaxEntries: 20000
| Ключ | По умолчанию | Смысл |
|---|---|---|
readScope | 'any' | 'any' — читать любой существующий путь; 'roots' — строгий allow-list |
allowRoots | [] | дополнительные корни для readScope: roots |
officeTimeoutMs | 120000 | бюджет конвертации Office → PDF |
copyFileTimeoutMs | 3000 | сколько ждать clipboard-лаунчер copy-file до ответа |
linkTimeoutMs | 5000 | таймаут загрузки метаданных ссылки |
linkMaxBytes | 524288 | максимум тела страницы для /link |
resolveMaxDepth | 8 | глубина поиска /resolve |
resolveMaxEntries | 20000 | сколько записей просматривать в /resolve |
Config-схема не экспортируется намеренно (не хотим тянуть schemastery в
пакет): неизвестные/битые значения игнорируются и заменяются значениями по
умолчанию.
Если в композиции есть сервис officeToPdf (в desktop-профиле это строка
office-to-pdf), host вызывает его лениво на каждый запрос:
convert({
extension: 'docx', // doc|docx|xls|xlsx|ppt|pptx
priority: 'foreground',
source: { key, version, bytes, read(signal, maxBytes) },
}, signal)
Сконвертированные PDF кэшируются в
$DSH_HOME/cache/file-preview/<sha256(absPath+mtimeMs+size)>.pdf. Запись
атомарная (tmp + rename), кэш best-effort: если каталог только для чтения,
предпросмотр всё равно работает. Изменённый файл автоматически получает новый
ключ. Старые PDF автоматически не удаляются — каталог можно чистить вручную.
Если сервиса нет, маршрут отвечает 501 office-unavailable — клиент показывает
карточку «открыть в приложении».
Клиентская половина ставит capture-фазный click-листенер на window и
перехватывает случаи, которые описаны в исследовании:
button с классом fileLink и путём в
title/тексте;img[src*="api/file?path="], путь берётся из
searchParams.get('path');[data-presented-file] button[title] (абсолютный путь);[data-changed-files] button[aria-describedby] и скрытый
span с абсолютным путём;a[href^="http"];[data-message-attachments] span[title]: только имя,
поэтому клиент зовёт /resolve.После перехвата клиент открывает модалку и общается только с host-маршрутами выше. Никаких правок в чужих пакетах и профиле для этого не требуется.
cd plugins/dsh-file-preview
node --check index.js
node scripts/check-bundle.mjs # манифест, патч, иконка, README, node --check
node scripts/unit-host.mjs # реальный loopback-сервер + заглушки
unit-host.mjs поднимает настоящий node:http на эфемерном порту и проверяет:
/meta для png/pdf/md/docx/txt, /raw (тип, длина, Range → 206,
content-disposition, HEAD), /text (md/txt, binary, Windows-1251,
maxBytes), /office-pdf (конвертация, кэш, 415, 501), fence (403 на
cross-site/чужой Origin/Host), 400/404, /action с заглушкой лаунчера
(точные команды explorer.exe / rundll32.exe), /link с заглушкой fetch,
/resolve (not-found, ambiguous), /action с заглушкой лаунчера
(точные команды explorer.exe / rundll32.exe, argv для copy-file на
win32/darwin, action-unavailable на Linux) и счётчики /status.
/status и канал ?seat=Клиентская половина добавляет ?seat=<state> к любому GET (например,
seat=overlay-mounted, seat=no-slot, seat=error:…). Host запоминает
последнее значение (до 80 символов) и отдаёт его в /status.lastClientSeat —
это единственный способ снаружи доказать, что бандл действительно смонтировался
в браузере, а не просто лежит в профиле.
Счётчики requests увеличиваются на каждый вход в маршрут; lastError
хранит последнюю ошибку {route, code, message, at}. Перезапуск Host-процесса
(или перезагрузка плагина) обнуляет счётчики.
Установка — только через Plugin Manager DSH (install_bundle с абсолютным
путём к plugins/dsh-file-preview); вручную профиль не редактируется. После
установки в профиле появляются:
package.json → dependencies["dsh-file-preview"] = "link:<path>";dsh.profile.bundles включает dsh-file-preview;file-preview в дереве Cordis.Включение/выключение — тумблер строки file-preview в Plugin Manager (или
set_plugin/set_bundle). Host-ESM подхватывается только после перезапуска
Host-процесса; клиентский бандл — после перезагрузки страницы (HMR, если
запущен watcher).
Откат: выключить строку file-preview, затем remove_bundle для
dsh-file-preview. Профиль и остальные пакеты при этом не меняются. Кэш PDF
лежит отдельно в $DSH_HOME/cache/file-preview/ и удаляется вручную.
node:path, таблица лаунчеров open/xdg-open, никаких предположений о
буквах дисков в логике.navigator.clipboard / writeClipboard); copy-file (сам файл) выполняет
host через системный file-drop clipboard там, где он есть (Windows/macOS).copy-file на Linux отвечает action-unavailable: портируемого API
file-drop clipboard в Linux нет, а xclip/xsel не гарантируют file list.dsh-native-command); на обычном Linux используется xdg-open./link парсятся регулярными выражениями, а не полноценным
HTML-парсером: этого достаточно для title/description/og-полей и иконки.