# DSH File Explorer — Especificação (v2, implementada) Plugin para o DeepSeek Harness (DSH) que adiciona à GUI web um **explorer de arquivos + editor de código** no estilo VS Code: árvore do workspace da sessão com CRUD completo, editor Monaco com numeração de linhas, abas múltiplas, coloração de sintaxe com grammars TextMate reais do VS Code (tema de ícones Seti + temas Dark+/Light+), integração com o agente (ação rápida) e locale seguindo a GUI. --- ## 1. Visão geral | Item | Decisão | |---|---| | Tipo | Pacote npm instalável no profile DSH via `dsh plugin` (bundle + cliente) | | Parte servidora | Plugin Cordis (patch `cordis.patch.yml`) com o serviço de arquivos RPC | | Parte cliente | Bundle **escrito à mão** no formato `window.__ModuleLoader__.load({id, factory})` — **sem etapa de build** (zero dependências de toolchain) | | Acesso a arquivos | RPC direto servidor↔cliente (canal `/explorer`), **sem** passar pelo LLM | | Raiz da árvore | Workspace da sessão atual (cwd da sessão); sem sessão → fluxo de abrir/criar workspace | | Permissões | Respeita o sandbox da sessão: toda operação confinada à raiz do workspace | | Editor | Monaco Editor (build AMD servida pelo próprio plugin) | | Coloração | Grammars TextMate reais do VS Code (via `vscode-textmate` + `vscode-oniguruma` WASM) + temas Dark+/Light+ mesclados | | Ícones | Fonte **codicon** do VS Code (UI/pastas) + tema de ícones **Seti** (arquivos, o padrão do VS Code) | | Posição na UI | Painel **encaixado como coluna real da grade do app** (redimensiona o chat), colapsável, redimensionável e **móvel** (esquerda/direita) | | Idioma | Segue o locale ativo da GUI (dicionários `pt`, `en`, `zh`) | | Autor | dgadelha1 | | Repositório | https://github.com/dgadelha1/dsh-explorer-plugin | | Licença | MIT | ## 2. Estrutura do pacote ``` dsh-explorer-plugin/ ├── package.json # dsh.bundle.patch + dsh.client + exports ├── cordis.patch.yml # insere a linha do plugin servidor ├── LICENSE # MIT ├── SPEC.md # este documento ├── lib/ │ ├── index.js # plugin servidor (ESM): RPC, rotas estáticas, SSE/watcher │ └── client.js # bundle cliente (factory CJS do __ModuleLoader__) — fonte única, sem build ├── src/ # cópias-fonte (exports ./src/*) mantidas sincronizadas ├── scripts/ │ ├── vendor.mjs # baixa os assets para vendor/ (idempotente; versões pinadas) │ ├── merge-themes.mjs # JSONC -> JSON estrito + merge da cadeia include dos temas │ ├── sync.mjs # copia src/ -> lib/ (--check falha se divergirem; roda no prepack) │ ├── server-test.mjs # teste de regressão do servidor (sandbox/allowlist, caps, watcher sem crash) │ ├── smoke-client.cjs # smoke test do bundle (loader stub em Node) │ └── syntax-test-driver.cjs # teste headless do pipeline TextMate (puppeteer + Firefox) └── vendor/ # assets servidos em runtime (commitados no repo) ├── monaco/ # monaco-editor (build AMD min; source maps removidos) ├── onig/ # vscode-oniguruma (onig.wasm + loader UMD) ├── textmate/ # vscode-textmate (release CJS/UMD) ├── grammars/ # .tmLanguage.json oficiais + manifest.json (escopo → arquivo) ├── themes/ # dark_plus.json / light_plus.json (JSON estrito, mesclados) ├── codicon/ # fonte codicon do VS Code (UI + pastas) └── seti/ # fonte seti + vs-seti-icon-theme.json (ícones de arquivo) ``` ### 2.1 Metadados do package.json ```jsonc { "name": "dsh-explorer-plugin", "type": "module", "main": "lib/index.js", "exports": { ".": "./lib/index.js", "./client": "./lib/client.js", "./src/*": "./src/*", "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "inject": [ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-theme", "@deepseek-ai/dsh-client-locale" ] } } } ``` O plugin servidor exporta `{ name: 'explorer', inject: ['webServer', 'connection'], apply(ctx) }`. ### 2.2 cordis.patch.yml ```yaml - insert: - id: explorer name: 'dsh-explorer-plugin' ``` ## 3. Parte servidora (`lib/index.js`) ### 3.1 Canal RPC `/explorer` Registrado com `ctx.connection.rpc.handle('/explorer', handler, { authority: 'loopback' })`. > O canal **não pode conter `/` interno** (`CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/`) — por isso `/explorer` e não `/rpc/explorer`. Handler `(endpoint, payload, signal) → RpcResult`. O cliente chama `ctx.connection.rpc.call('/explorer', endpoint, payload)` → `POST /explorer/`. Endpoints (todos com `{root, …}`; caminhos sempre relativos à raiz): | endpoint | payload | retorno | |---|---|---| | `fs/stat` | `{root, path}` | `{exists, path, name, isDir, size, mtimeMs, hidden}` (missing → `{exists:false}`) | | `fs/list` | `{root, path, includeHidden}` | `{path, entries:[{name,path,isDir,size,mtimeMs,hidden}]}` (pastas 1º, nome-sorted; dotfiles filtrados por `includeHidden`) | | `fs/read` | `{root, path}` | `{content, size, mtimeMs}` — binário → `{binary:true}`; > 2 MB → `{tooLarge:true, size}` | | `fs/readLarge` | `{root, path}` | conteúdo até **50 MB** (acima → `{tooLarge:true, size}`; usado para abrir read-only) | | `fs/write` | `{root, path, content}` | `{written, mtimeMs, size}` (escrita atômica temp+rename com `O_EXCL` e cleanup do temp; mkdir -p do pai; **payload limitado a 50 MB**) | | `fs/create` | `{root, path, kind:'file'\|'dir'}` | `{path}` (falha `directory-exists` se já existe) | | `fs/rename` | `{root, path, newName}` | `{path}` (mesmo diretório) | | `fs/move` | `{root, path, targetDir}` | `{path}` (outro diretório; colisão → `directory-exists`) | | `fs/delete` | `{root, path}` | `{deleted:true}` (arquivo ou pasta recursiva; raiz bloqueada) | Regras: - **Confinamento/sandbox**: `path.resolve(root, …)` + verificação de prefixo; caminhos existentes passam por `realpath` do ancestral mais profundo (bloqueia symlink que escape da raiz). Escapar → `bad-request`. A rejeição do confinamento roda **fora** do walk de ENOENT (senão o escape seria engolido e a escrita vazaria — bug real encontrado e corrigido no repasse de segurança). Reads re-confirmam o `realpath` do arquivo imediatamente antes do I/O (janela TOCTOU reduzida). - **Root canônico no dispatch**: o servidor canonicaliza (`realpath`) o root uma vez por chamada e passa o caminho canônico aos endpoints — workspaces alcançados via symlink funcionam, e os guards `abs === root` (delete da raiz) valem mesmo com root simbólico. - **A raiz do workspace é intocável por rename/move/delete** (`.`, `''` → `bad-request`). - **Somente arquivos regulares são lidos**: `fs/read`/`fs/readLarge` rejeitam FIFO/socket/device (`isFile()`), evitando que `readFile` trave o handler (DoS). - **Listagens não vazam metadados**: symlinks que resolvem para fora da raiz são ocultados do `fs/list` (realpath + confine por entrada). - **Root validado no servidor (não confiado ao cliente)**: o `root` enviado pelo cliente precisa ser o cwd canônico de uma sessão viva ou um path do workspace registry — caso contrário `bad-request`/`403`. Isso impede ler/gravar diretórios arbitrários (`/`, `/etc`, `~`) pela API loopback. O canal RPC já é protegido contra CSRF pela plataforma (`isTrustedApiRequest`: Host loopback + Origin/same-site). - `root` validado como diretório existente a cada chamada. - Códigos de erro apenas do schema RPC compartilhado (`bad-request`, `directory-exists`, `directory-unreadable`, `internal`) — o schema do cliente rejeita códigos desconhecidos. Dispatch de endpoints via `hasOwnProperty` (`__proto__`/`constructor` → `bad-request`). - Binário detectado por byte NUL nos primeiros 8 KB. ### 3.2 Rotas web (webServer) | rota | tipo | função | |---|---|---| | `/explorer-assets` | prefix | serve `vendor/` com MIME correto e `Cache-Control: no-cache` | | `/explorer/events` | exact | **SSE** do watcher: `data: {"type":"fs","root":...,"events":[...]}` (heartbeat 25 s; 503 sem watcher) | ### 3.3 Watcher - `fs.watch(root, {recursive:true})` (Node ≥ 20, inotify) com debounce ~120 ms; fallback não-recursivo se recursivo falhar. - **`error` do watcher tratado**: um `FSWatcher` sem listener de `error` derruba o processo Node inteiro (ocorreu em produção). Agora o handler fecha o watcher, acorda os clientes SSE uma vez (refresh) e agenda **uma única recriação** após 2 s — nunca crasha o servidor. - Uma instância por raiz ativa, compartilhada entre conexões SSE (refcount por cliente). - Eventos agrupados → broadcast para os clientes daquela raiz; o cliente faz refresh da árvore com debounce. ## 4. Parte cliente (`lib/client.js`) ### 4.1 Registro e arquitetura - Bundle no formato `window.__ModuleLoader__.load({id:'dsh-explorer-plugin', factory})`, exportando `apply` + `inject`. - `inject` (serviços): `['slots','layout','connection','sessions','workspaces','locale','theme']`. - `apply(ctx)`: registra dicionários `explorer` (pt/en/zh) e o componente `ExplorerPanel` no slot `shell.overlay` (list, root) do `ui-layout`. - Dependências de runtime do bundle: apenas `react` (via `require`); todo o resto via serviços do `ctx`. CSS injetado via `