中文 · npm · GitHub
A DeepSeek Harness (DSH) web plugin that opens and edits .docx / .xlsx / .pptx in the dsh-better-sidebar file sidebar through a self-hosted ONLYOFFICE Document Server — with JWT-signed configs, atomic save-back to disk, and live refresh when the AI edits an open file.

Features
- Real office editing — the full ONLYOFFICE editor (words / sheets / slides) embedded in the better-sidebar viewer, not a static preview. Saves write back to the file on disk.
- Signed and fenced by default — editor configs are HS256-JWT-signed when a secret is configured; every file download is gated by a short-lived HMAC token; browser-facing routes sit behind the dsh web trust fence; files outside the session working directory are refused.
- Live refresh on AI edits — when the agent modifies a file that is open in the editor, the viewer swaps to the new version within ~a second via
refreshFile, with no page reload and no api.js reload. A dirty editor is never auto-refreshed (a banner offers a manual reload instead).
- Atomic saves, self-save suppression — Document Server callbacks download the saved bytes and replace the file through temp-file + rename, serialized per document key; the plugin's own writes are absorbed by the watch hub so saving never echoes a refresh back.
- In-network save fetch — optional
internalDocumentServerUrl rewrites Document Server save URLs onto a docker-network base (container-to-container) instead of hairpinning through the public reverse proxy.
- Coexists with the office preview plugin — viewer ids
onlyoffice:docx|xlsx|pptx (priority 10) never clash with @huanlin/dsh-plugin-better-sidebar-plugin-office's docx/xlsx/pptx (priority 0); either side can be disabled per-viewer in the side card settings.
How it works
| Host (server) | src/ | Registers four routes on the dsh web webServer implementing the "document storage service" role from the official ONLYOFFICE integration docs. |
| Browser (client) | src/client/ | Registers the three better-sidebar file viewers; loads the Document Server api.js on demand, mounts DocsAPI.DocEditor, subscribes to the SSE watch stream. |
better-sidebar viewer (onlyoffice:docx|xlsx|pptx)
├─ GET /sidebar/onlyoffice/config (browser, trust-fenced)
│ → editor config + JWT + api.js URL + HMAC file token
├─ GET /sidebar/onlyoffice/file (Document Server; HMAC token = auth)
├─ POST /sidebar/onlyoffice/callback (Document Server; status 2/6 → atomic save-back)
└─ GET /sidebar/onlyoffice/watch (browser SSE; inotify-fed disk changes)
→ change → re-fetch config → docEditor.refreshFile(config) [no reload]
- Node half — the config route builds the editor config for an absolute path inside the session cwd (documentType mapping, content-addressed
key = sha256(host+path+size+mtime)), signs the whole config as an HS256 JWT, and registers the key→file mapping. The file route serves the raw bytes to the Document Server. The callback route (JWT-verified) downloads saved bytes on status 2/6 and atomically replaces the file, forgets the key on status 4, and always acknowledges with {"error":0}. The watch route streams disk changes as SSE, fed by an inotify watch hub (fs.watch on the file and its directory, debounced, signature-filtered through the same content-addressed key).
- Browser half — loads the Document Server api.js once per URL, mounts the editor, calls
destroyEditor() on teardown; failures show an error panel plus a download fallback link (reusing better-sidebar's /sidebar/file route). While mounted it subscribes to the watch stream and reacts to outside (AI) edits by re-fetching the config and calling docEditor.refreshFile(config).
Live refresh on AI edits
When an agent modifies a file that is open in the ONLYOFFICE editor, the viewer finds out within ~a second: inotify → SSE change event → config re-fetch (whose content-addressed key changed with the file) → refreshFile. The editor iframe itself is reused — no page reload, no api.js reload, and the DS opens the new version as a fresh session (old key closed with status 4). Guard rails:
- A dirty editor is never auto-refreshed —
refreshFile unconditionally drops unsaved edits (no isDocumentModified guard on the integrator path, verified in the DS source). Instead a banner appears ("file changed on disk — reloading discards unsaved edits") with a manual Reload button; clicking it is the user's confirmation.
- The DS's own
onRequestRefreshFile (fired on reconnect / same-key saves, only while NOT modified) rides the same refresh path.
- The plugin's own callback saves are suppressed server-side (
noteSelfSave records the just-written signature), so a user save doesn't bounce a refresh back and reset the cursor.
- File deletion pushes a
removed notice; re-creation pushes a change.
Requirements
- A DSH web profile (
dsh web), Node.js ≥ 20, with the dsh-better-sidebar plugin installed.
- A self-hosted ONLYOFFICE Document Server (verified against 9.4 community edition) that can reach the dsh web server over the network, and that the browser can reach directly.
- With JWT enabled (the DS default), the plugin's
jwtSecret must equal the DS JWT_SECRET.
- The DS needs
ALLOW_PRIVATE_IP_ADDRESS=true when document.url / callbackUrl are private addresses (the DS refuses them by default).
Installation
From the npm registry (prebuilt — no build permission needed):
dsh plugin --profile web add dsh-better-sidebar-onlyoffice
From a GitHub repository (source — pnpm runs the prepare build; allowlist the package in profiles/web/pnpm-workspace.yaml if pnpm blocks the build script):
dsh plugin --profile web add github:chendefine/dsh-better-sidebar-onlyoffice
Or through the DSH plugin marketplace (设置 → DSH插件市场) — the repo carries the dsh-plugin topic and is indexed automatically.
After a bundle plugin is added to the profile layer stack, write your config into the profile's cordis.patch.yml layer (see below), restart dsh web, and hard-refresh the browser (Ctrl+Shift+R). Uninstall with dsh plugin --profile web remove dsh-better-sidebar-onlyoffice and restart again.
Configuration
All keys are optional; the profile's cordis.patch.yml layer carries them:
- id: dsh-better-sidebar-onlyoffice
config:
jwtSecret: "<the DS JWT_SECRET>"
jwtSecret | '' | Shared secret with the Document Server (its JWT_SECRET). Editor configs and callbacks are JWT-signed/verified when set; empty only fits a DS running JWT_ENABLED=false. |
documentServerUrl | (derived) | Browser-side DS base URL (e.g. http://192.168.1.10:3082). Empty = derive from each request's Origin host + documentServerPort. |
documentServerPort | 3082 | Port appended when deriving the DS URL from the page Origin. |
internalDocumentServerUrl | '' | Base URL this server fetches DS-reported save URLs through (docker-network direct, e.g. http://onlyoffice-documentserver). Empty = fetch exactly as reported (through the public entry). |
internalBaseUrl | '' | Base URL the DS uses to reach this dsh web server (document download + callbacks). Empty = auto-detect the first non-loopback IPv4 + the webserver port. |
defaultMode | edit | Default open mode; the viewer's gear setting overrides it per user. |
fileLimitMb | 100 | Max file size (MB) for serving and saving back. |
tokenTtlSec | 600 | Signed URL token lifetime in seconds. |
Each viewer's gear settings add two shared toggles: open mode (edit/view) and document server URL (browser-side override; a value set on any card applies to all three viewers).
In-network save fetch (internalDocumentServerUrl)
The three directions are configured independently: browser → DS via documentServerUrl, DS → this server via internalBaseUrl, and this server → DS (downloading saved bytes) defaults to the URL the DS reports in its callback — behind a reverse proxy that points back through the public entry, so save traffic hairpins out (DNS + TLS + terminator + gateway). Setting internalDocumentServerUrl (e.g. http://onlyoffice-documentserver, the docker-network container name) rewrites the browser-side prefix (scheme/host/sub-path) of reported save URLs onto that base — path suffix and query stay intact — for a direct container-to-container fetch.
Safety verified against Document Server 9.4: /cache/files auth is an nginx secure_link md5 over expires + request-path + server secret, where the request path is the one AFTER the proxy strips the sub-path prefix — host and stripped prefix are not signed material, so a rewritten URL still returns 200. A reported URL outside the browser base is fetched as-is with a warn log.
Deployment shape
The canonical deployment: the Document Server container and dsh web share a docker network (DS container name resolvable, e.g. onlyoffice-documentserver), DS published on host port 3082, JWT enabled with a fixed secret, and ALLOW_PRIVATE_IP_ADDRESS=true on the DS container.
Troubleshooting: editor reports errorCode:-4 "download failed"
-4 means the DS could not download document.url. Start from the target URL in the DS container log (error downloadFile:url=...):
- URL points at the DS itself →
internalBaseUrl was misconfigured to the DS address. It must be the address the DS uses to call back into dsh web (use the container name when both share a docker network); editing the profile's cordis.patch.yml hot-applies via Cordis HMR, no restart needed.
- 404 → wrong host in the URL; 403 → token expired/bad signature (reopen the file after a dsh web restart); connection refused/timeout → network unreachable (both containers must share a network; the DS needs
ALLOW_PRIVATE_IP_ADDRESS=true).
- Quick check without a browser: fetch the config route with
Host: localhost to get document.url, then docker exec onlyoffice-documentserver curl -v <that URL> — expect 200.
Known limits
- Saves are whole-file overwrites: concurrent writes to the same file by the agent race (last writer wins). Live refresh narrows this window — a clean editor is pushed to the newest version within ~a second — but a dirty editor that saves after an AI edit still overwrites it; the stale banner is cleared on save (the overwrite resolved the divergence).
- Self-save suppression has a millisecond race: an external write landing between the plugin's save and its
noteSelfSave stat can be wrongly absorbed (one missed refresh event; the next change recovers). Negligible in practice.
onRequestRefreshFile requires ONLYOFFICE Docs ≥ 8.3; refreshFile itself was verified against Document Server 9.4 community edition.
- The browser must reach the Document Server directly (mixed-content rule: an https GUI cannot load http api.js — put a proxy in front or point
documentServerUrl at an https entry).
- A dsh web restart loses the in-memory key→file mapping; editors already open in the DS will see error 1 on their next save callback (the DS retries, then gives up) — reopening the file is the fix. The watch stream reconnects on its own (EventSource
retry: 3000).
Security
The host half only ever serves and writes files inside a session working directory, browser-facing routes sit behind the dsh web trust fence, and every Document-Server download is gated by a short-lived HMAC token minted per config request. The Document Server itself is user-deployed and user-configured — deploy it on a trusted network. See SECURITY.md for the full stance and threat model.
Development
pnpm install
pnpm run typecheck
pnpm test
pnpm run build
Repository layout:
src/
├── index.ts # host entry: the four webServer routes
├── config.ts # schemastery schema, base-URL derivation/detection
├── onlyoffice.ts # editor config, JWT payload, file tokens, save-URL rewrite
├── jwt.ts # minimal HS256 sign/verify (no dependencies)
├── registry.ts # document key → file mapping, atomic save-back
├── watch.ts # inotify watch hub (debounce, signature filter, self-save absorption)
├── trust-fence.ts # browser-request trust check (host/origin)
├── paths.ts # absolute-path + containment helpers
├── wire.ts # JSON body/error helpers
└── client/ # browser half: viewers, editor mount, i18n, settings
License
MIT