# 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=`); a later tab carries its `cwd` on `create`. Either way the host fences the directory (below) before spawning, so an unusable value falls back to the workspace root rather than being obeyed. ## Design notes **A write target may not be a symlink.** The root fence realpaths its target, which catches a symlink pointing outside the root — unless the target does not exist yet, in which case there is nothing to realpath and it anchors on the parent instead. A *dangling* symlink as the last path component has an in-root parent, so it passed, and then a following write (`upload` uses raw `writeFile`) landed wherever the link pointed — outside the root, outside every writable sandbox root. Confirmed before the fix: an upload through a planted symlink wrote a file into `$HOME`. Write, upload and mkdir now `lstat` the target and refuse a symlink outright (`403 symlink_target`); a symlink anywhere but the last component was already caught, because realpath of the parent follows it. Rename and delete still operate on the link itself, which is the point of them. **Text means UTF-8, and it is checked.** Decoding never throws — invalid bytes become U+FFFD — so a GBK-encoded Chinese file would otherwise be served as mojibake that looks like the file's real contents. The read route round-trips the decode and answers `415 not_utf8` instead, which the preview explains in words. **Filename search normalizes Unicode.** macOS stores names decomposed (NFD) while people type the composed form, so `café` would miss a file literally called café; both sides are normalized to NFC before matching. **Workspace bytes cannot script the app.** A workspace is full of files nobody on this side wrote — a cloned repo, a package's assets, whatever an agent just generated — and `bytes` hands them back on the app's own origin. An SVG is a document, not merely a picture: navigating to one containing `