# dsh-plugin-workbench A file browser, file preview, and browser terminal for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web UI, as an out-of-tree plugin. ## Install Not on npm yet. The built `lib/` is committed, so it installs straight from GitHub — no download, no build step: ```sh dsh plugin --profile web add github:ghbhiee/dsh-plugin-workbench ``` Or from a local clone (a `link:`, so a local rebuild is picked up): ```sh git clone https://github.com/ghbhiee/dsh-plugin-workbench.git dsh plugin --profile web add ./dsh-plugin-workbench ``` Sibling plugins install the same way: [dsh-plugin-workbench](https://github.com/ghbhiee/dsh-plugin-workbench), [dsh-plugin-mobile-shell](https://github.com/ghbhiee/dsh-plugin-mobile-shell), [dsh-plugin-cli-session](https://github.com/ghbhiee/dsh-plugin-cli-session). ## Develop ```sh pnpm install pnpm run check # typecheck → vitest → tsdown build ``` Because `lib/` is versioned (it is what a git install serves), rebuild and commit it with every source change. ## Configure Everything that mutates or spawns is off by default. Override in the profile's `cordis.patch.yml`: ```yaml - id: workbench config: ptyEnabled: true readRoots: - /Users/me/notes ``` | Field | Default | Meaning | |---|---|---| | `readRoots` | `[]` | Extra absolute directories the browser may read, beyond the session workspace root. Checked at load: a relative path or a missing directory fails the plugin with both problems named, rather than becoming a broken entry in the picker | | `writeEnabled` | `false` | Allow write/upload/mkdir/rename/delete, and show the editing UI | | `ptyEnabled` | `false` | Allow the browser to spawn shells | | `loopbackOnly` | `true` | Refuse callers that did not arrive over loopback | | `maxListEntries` | `1000` | Cap on entries per directory listing | | `shell` | `''` | Shell to spawn; empty detects `$SHELL` → zsh → bash (PowerShell on Windows) | ## Surface | Route | Purpose | |---|---| | `GET /plugins/workbench/api/roots` | Readable roots | | `GET /plugins/workbench/api/list?root=&path=` | Directory listing with type, size, mtime, and what a symlink resolves to | | `GET /plugins/workbench/api/search?root=&path=&q=` | Filename search under a directory, bounded by results/entries/time | | `GET /plugins/workbench/api/stat?root=&path=` | Type, size and version — cheap enough to poll while a preview is open | | `GET /plugins/workbench/api/read?root=&path=` | Text contents plus a freshness `version` (2 MiB cap, refuses binary and non-UTF-8) | | `GET /plugins/workbench/api/bytes?root=&path=[&download=1]` | Raw bytes with a MIME type, for images and PDFs; `download=1` sends it as an attachment instead of inline | | `PUT /plugins/workbench/api/write?root=&path=[&version=]` | Body is the new text; with `version` the write is conditional, and the reply carries the new version | | `POST /plugins/workbench/api/upload?root=&path=` | Body is raw bytes — no multipart parser in the loop | | `POST /plugins/workbench/api/mkdir?root=&path=` | Create a directory (recursive) | | `POST /plugins/workbench/api/rename?root=&path=&to=` | Move within the same root | | `DELETE /plugins/workbench/api/delete?root=&path=[&recursive=1]` | Remove; a directory needs `recursive=1` | | `WS /plugins/workbench/pty` | Terminal gateway | **Saving keeps you in step.** The write reply carries the file's new version, so the editor can go on editing and the freshness poll has something to compare against — without it, every save was followed by a needless re-read of the whole file. **A save cannot clobber someone else's.** `read` hands back the fs seam's freshness token and the editor sends it back on save, so a write whose basis moved on is refused with `409 stale_version` and the draft stays in the textarea rather than being lost or overwriting the other change. Uploads and unconditional writes still work as before. **Nothing is replaced silently.** POSIX `rename()` overwrites its destination without a word, so renaming onto an existing name destroyed it and answered `{ok:true}`; that now needs an explicit `overwrite=1` and otherwise fails with `409 destination_exists`. Writes and uploads legitimately replace, so they report `overwrote` and the browser names what it replaced. **A root is never a target.** An empty `path` resolves to the root itself, which made `DELETE ?path=&recursive=1` answer `{ok:true}` after removing the entire workspace. Create, rename, and delete now refuse a root outright; listing one is still fine. Every mutating route is refused outright unless `writeEnabled` is on, and then has to clear, in order: the sandbox mode (`read-only` refuses everything), a protected-name and protected-segment list (`.env`, `auth.json`, `id_rsa`, `.git/`, `.ssh/`, `node_modules/`, …), the read-root containment fence, and the sandbox policy's writable roots (workspace root, `/tmp`, the OS temp dir) unless the mode is `danger-full-access`. Rename checks the destination as well as the source. Bodies over 32 MiB are rejected while streaming, before anything is written. Terminal protocol — a bare string is keystrokes in, terminal output back; JSON is control: - client → server: `{type:'create'[,cwd]}`, `{type:'switch',sessionId}`, `{type:'close',sessionId}`, `{type:'resize',cols,rows}` - server → client: `{type:'created',id,pid,shell}`, `{type:'switched',id}`, `{type:'exited',id,exitCode}`, `{type:'error',message}` The first shell's directory rides on the handshake URL (`…/pty?cwd=