# Architecture and Pitfalls English | [中文](ARCHITECTURE.zh.md) This document combines the current source contract with measured historical incidents. Older numbered sections name the hosts investigated at the time; they are not fresh acceptance of every current host. ## 2.3.1 source contract These additions are prepared for patch `2.3.1`; they do not migrate assistant files or rewrite their format. The candidate/publication state is tracked in [current compatibility](CURRENT_COMPATIBILITY.md#231-validation). YAML quoting around `!!js` is decoded before interpretation. Known disable conditions remain native `__jsExpr` nodes for the official loader's real context; unsupported context conditions are truly disabled. The editor understands the shipped `profileContext` condition using only a request-scoped immutable boolean snapshot, isolated with AsyncLocalStorage, without retaining service objects or adding a hard dependency. Successful non-context expressions keep their prior evaluation behavior and native Boolean result. Diffing compares decoded expression sources while preserving literal-versus-expression tri-state and untouched source bytes. - `GET /api/custom-mode/state` returns an opaque `revision` over the raw bytes of `prompt.md`, `preset.yml` and `agent.cordis.yml`, including explicit missing-file state. New saves send `baseRevision`; any changed or unreadable editable field yields `conflict` before writing. Legacy numeric `baseN` still compares prompt history, independently of audit append success. A legacy request with neither token retains its overwrite semantics. - Repair sends `baseRevision`. Reorder sends a `baseRevisions` map for all loaded assistants; every supplied baseline is checked before any reorder write. Successful save/repair returns `revision`, while successful reorder returns only its own `revisions` map. These tokens are calculated from the exact intended writes before commit, without a post-commit file read. A failed persistence operation or rolled-back reorder does not authorize adopting tentative tokens; a saved-but-unsynchronized response retains the completed write's token alongside `persisted:true`. - Dirty drafts retain their original content and baseline during ordinary reads. Only a checked successful local mutation can advance its affected baseline; external reads cannot silently bless changes the draft has not incorporated. Draft memory belongs to the current plugin context/browser document, survives settings-section remounts, and is cleared on plugin disposal. It is not localStorage or a disk backup; reload/close reminders do not recover confirmed losses. - The clone uninstaller treats the host removal result as authoritative. Failure or unverifiable remaining profile declarations stop later cleanup/purge. Confirmed leftover package cleanup stays within the named package path and preserves a shared installation referenced by another profile; desktop profiles remain owned by the app. ## Inherited contract from 2.3.0 - The preset owns prompt content; the root profile bundle owns the editor. The eight exact paths in §5 stay on the carrier's authenticated shared `/api` channel. `POST /api/custom-mode/sync` retries only registration, never a save/create/delete/reorder operation. - The loop supplies model/provider. On hosts with workingDirectory, the reader provides a scoped cwd compatibility variable from the real session directory and refreshes it after host assembly/ensure. Older hosts retain their native cwd. Only the exact old standalone directory sentence is normalized at render time; no user prompt/suffix file is rewritten (§7). - Known reader-owned directories recover before discovery requires prompt.md. A prepared transaction restores one whole old generation; a committed one keeps the whole new generation. Unique legacy backup groups recover before missing user files may be filled, including in the clone installer through the same restoreBackups implementation. A live/uninspectable owner, ambiguous generations or invalid journal fails closed and preserves evidence. This is not cross-process writer coordination or a physical power-loss durability guarantee. - Disk mutation and registry synchronization share a queue; readers wait for synchronization. HTTP503 savedButSyncFailed means persisted:true, synchronized:false, with the completed operation code. Retry only synchronization. The sync-only failure's persisted:false describes that retry, not an undo of an earlier save. The backend also serializes mount/unmount/sync and retains failures for retry. - Declarative registration disposers belong to the declaring plugin scope. Its cleanup closes admission immediately, drains in-flight mutations, releases late-returning registrations through their own disposers and attempts every owned cleanup while retaining failed handles. Cordis waits for cleanup but logs its rejection rather than guaranteeing that the next generation is blocked; native success therefore does not replace checking cleanup logs and registration health. A later generation never takes ownership of another generation's preset id. - List responses expose overall `registrationSync`; state responses scope it to that assistant or an unknown/global failure, using only typed codes and bounded ids. Warnings do not imply a healthy assistant is broken. Exact registration conflicts/cleanup failures may suggest restart; malformed or missing composition does not become a restart requirement. Released 2.2.6 has no retroactive cleanup and retains its explicit first-migration restart boundary. - The page header reads the running version from the existing list response. Manual update checks use the host's optional native plugin-manager service, confirmed in `0.2.2-alpha.2`; the older checked hosts fall back to public plugin-details navigation and a guide. Query/update permissions are separate where the host exposes them. Installation, confirmation, progress and self-update lifecycle belong to the official Plugins details, with all held drafts and pending recovery guarded before navigation. No new update route, shell updater or credential handling is added; file/link/tarball sources are not converted to registry installs. The current component and platform evidence is recorded in [CURRENT_COMPATIBILITY.md](CURRENT_COMPATIBILITY.md). Historical API examples below remain useful for understanding the design, with these current contracts taking precedence. ## 1. Why the settings page must be split into a standalone plugin and cannot be written as one row of a preset This is the single most important constraint in the whole project. In an agent preset's composition file (`agent.cordis.yml`), a row **may** reference a bundled module: ```yaml - id: persona name: './prompt-reader.mjs' ``` That works because a preset's relative paths are resolved against the preset directory (`dsh-agent-presets`'s `classifyRowSpecifier` classifies `./x` as the `preset` type, and imports it through the `Include` subtree whose baseUrl has been rewritten). **But the browser half does not.** The web client's module scanner (`@deepseek-ai/dsh-client-modules`): - runs during **startup assembly** — in its constructor, `for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)`; - reads only the **root loader's entries**; - on a miss it does `throw new ClientPackageCompositionError`, and **the entire boot fails**. Meanwhile, an agent preset's rows are mounted by `dsh-agent-presets` through the `Include` subtree's `PresetTree`, **scoped per session at runtime**, and are **not in the root loader**. Measured data (queried in a real run): ``` root loader entries: 161 client graph entries: 56 ← none of them are preset rows ``` So a browser half written inside a preset is **never discovered** and only becomes dead code. The only plane that can mount a browser half is the **root plane** (the profile). **Conclusion**: split into two artifacts — a preset (files) + a settings-page plugin (profile bundle). ## 2. How a bundle plugin gets assembled The settings-page plugin is a **bundle**: `package.json` declares `dsh.bundle.patch`, and the profile's `dsh.profile.bundles` lists it. Composition order (`composeProfile`): ``` bundlePatches → profile.cordis.patch.yml → $DSH_HOME/cordis.patch.yml → --patch overlays ``` Two easy-to-get-wrong points: - **A bundle patch adds rows with `- insert:`**, whereas **the profile's own `cordis.patch.yml` can only replace rows that already exist** (adding one reports `entry not found`). In the first version the new rows were written into the profile patch and were rejected outright. - Package resolution first tries `INSTALL_ANCHOR` (the dsh install directory), and on failure **falls back to the profile directory**: ```js for (const anchor of [installAnchor, join(profileDir, "package.json")]) { … } ``` So a package installed into the profile by `dsh plugin --profile

add .` can be resolved, without needing to be installed into the dsh install directory. ## 3. `dsh.client.inject` must be declared (this bug kept the page from appearing at first) The browser half needs to read a service: ```js const slots = ctx.get("slots") ``` **That alone does not work.** The client registry guards service reads, and every official settings plugin additionally exports: ```js exports.apply = apply exports.inject = inject // inject = ["slots"] ``` The symptom is subtle: the client graph **has an entry, with the correct rev, and the bundle is served**, yet the page simply never appears — because `apply` was rejected by the guard and registration never happened. Adding `exports.inject` fixed it immediately. ## 4. The browser half's hand-written bundle This package's `client.js` is **hand-written**, not a build artifact. Format: ```js window.__ModuleLoader__.load({ id: "dsh-custom-mode", factory: (require) => { var module = { exports: {} } var exports = module.exports const react = require("react") // ← resolved from the "seed module table" function apply(ctx) { … } exports.apply = apply exports.inject = ["slots"] return module.exports }, }) ``` The seed module table is provided by the web frontend; in 0.1.6 it contains: ``` react, react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis, @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit ``` So `require("react")` works, **with no bundler and no installed dependencies needed**. The whole `load()` call is wrapped in `try/catch`, so this package cannot crash the page — the worst case is that this one page does not appear. ## 5. Talking to the host: a private HTTP route The browser half has no `host.call` (that is the channel reserved for dynamic Cordis packages); a statically loaded plugin goes through `ctx.remote.` — which requires the host to register a Remote namespace and a typed contract, and is heavy and error-prone. This project instead uses **eight exact routes on the platform's shared `/api` channel**: | Path | Methods | Purpose | | --- | --- | --- | | `/api/custom-mode` | GET | Assistant list | | `/api/custom-mode/state` | GET, POST | Read/save an assistant | | `/api/custom-mode/history` | GET | Read versions | | `/api/custom-mode/create` | POST | Create | | `/api/custom-mode/delete` | POST | Delete | | `/api/custom-mode/reorder` | POST | Reorder | | `/api/custom-mode/repair` | POST | Repair unavailable/broken rows | | `/api/custom-mode/sync` | POST | Retry registration without rewriting files | - host half: `ctx.connection.fetch.register({ path: "/api/custom-mode/state", methods: ["GET", "POST"], requestBody: "buffered", fetch })` — one registration per path - browser half: `fetch("/api/custom-mode/state?id=…", { method: "GET" | "POST" })` Advantages: fully self-contained, it occupies no Cordis service name and cannot collide with anyone else, and there is no need to understand the Remote generation mechanism. And — the reason the channel is `/api` and not the bare `webServer` table — **the carrier that owns `/api` applies the platform's trust and authentication policy before dispatching to any route**, which makes the fence structural rather than a rule to remember (see §5.1). (One consequence of the exact-path registry: the registered `path` is the **absolute** path *including* `/api`, which is what the platform's own packages pass. And a method a route does not declare is never dispatched to it — a `GET` on a POST-only path gets the channel's 404, not a 405.) ### 5.1 Why the route lives on `/api`: a bare route is **not** inside the platform's browser trust fence (measured, fixed in 1.0.3) The approach above had a consequence that was not realized at the time. `ctx.webServer` is a **bare HTTP table**; the platform's Host/Origin fence plus browser authentication are attached by `@deepseek-ai/dsh-client-connection` to the channel **it mounts itself** (`/`, `/api`…), and a route registered directly on `webServer` **does not pass through** it. Measured (0.1.6-alpha.1, before this plugin was fixed): ```sh # Unauthorized GET: hands over the entire system prompt curl http://127.0.0.1:3081/custom-mode → 200 {"ok":true,...,"prompt":"You are a coding agent powered by ..."} # Unauthorized POST: rewrites prompt.md directly curl -X POST http://127.0.0.1:3081/custom-mode \ -H 'content-type: text/plain' --data '{"mode":"standard","overrides":{},"prompt":"PWNED"}' → 200 {"ok":true,...} # the file really was modified # The official routes on the same machine, for comparison curl http://127.0.0.1:3081/ → 401 curl http://127.0.0.1:3081/api/settings → 401 ``` The `content-type: text/plain` part makes it more than "any local process can write": **an ordinary form-style cross-site request needs no preflight**, so any web page the user visits can POST to this port and change the agent's system prompt to whatever the attacker wants (the response cannot be read, but the write has already happened). For an agent holding Shell and file tools, this is a genuine prompt-injection channel. **The fix**: wire the platform's own verdict into this route — `ctx.connection.requestRejection(req)` (the Host/Origin fence + browser session validation), which yields the same decision `/api` gets: ```js const rejection = ctx.get('connection').requestRejection(req) // 403 / 401 / undefined if (rejection !== undefined) { res.writeHead(rejection); res.end(); return } ``` Measured results after the fix: ``` Unauthorized GET → 401 unauthorized Unauthorized POST (text/plain) → 401, file not modified POST + Origin: https://evil.example + Sec-Fetch-Site: cross-site → 403 forbidden With a valid dsh-auth-* cookie (browser session) → 200 (works as before) ``` **Two reusable conclusions**: - A plugin registering a route on `ctx.webServer` = it owns its own security. Anything that needs browser access must first pass `connection.requestRejection`; for routes meant only for local processes, be aware that **any** local process can call them. - The `connection` service is **not ready yet** when a bundle row's `apply()` runs (measured: putting it in `inject` leaves the plugin stuck at `pending`), so `ctx.get('connection')` must be fetched lazily **at request time**, and must **fail closed** when unavailable. And after the fix (this plugin 1.0.3, dsh `0.1.6-alpha.2` — routes on `/api`, no hand-rolled check anywhere): ```sh curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3095/api/custom-mode → 401 (no cookie) curl -s -b "$JAR" -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3095/api/custom-mode → 200 curl -s -b "$JAR" -o /dev/null -w '%{http_code}\n' -H 'Origin: https://evil.example' \ -H 'Sec-Fetch-Site: cross-site' http://127.0.0.1:3095/api/custom-mode → 403 curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' http://127.0.0.1:3095/api/custom-mode → 403 curl -s -b "$JAR" -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3095/custom-mode → 404 (the bare route no longer exists) ``` The same run also re-checked the write path: an unauthenticated `POST /api/custom-mode/state` answered 401 and left `prompt.md` untouched, while the authenticated call wrote both files. See `docs/MEASUREMENTS.md` §16. ## 6. Why the `dsh-settings` API is not used The host half of the community plugin `dsh-session-prompt` is: ```js import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings"; ``` On dsh `0.1.6-alpha.1`, the **actual exports** of `dsh-settings` are only: ``` SettingsConflictError, SettingsProvider, default, redactSecrets ``` Those two functions **do not exist anywhere in the 0.1.6 codebase**. Because this is the bundle layer, a failed import in the host half takes down the boot. This project therefore **does not use** `dsh-settings` at all: the prompt lives in an ordinary file, and the settings page reads and writes it itself. ## 7. Validating `{{…}}`: turning a local error into a mode-level failure `@deepseek-ai/dsh-system-prompt`'s rendering rules: ```js const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ // a complete {{x}} group: the name must be valid and registered, otherwise throw // an unclosed single {{ : treated as a literal ``` The consequence of throwing is not "this sentence has no effect" but **every request in that mode fails**. Both write paths validate model/cwd/provider, but registration is host-dependent: old loops provided cwd, while current loops provide model/provider and the reader obtains cwd from the official scoped workingDirectory service. A valid variable name alone is not proof it is available for rendering. The reader removes only the exact legacy standalone sentence `Your working directory is {{cwd}}.` when the new service is available, at runtime. Custom surrounding text and custom cwd references remain; the session's real directory is used, including changes made by the host's ensure lifecycle. Directory values are interpolated once, so braces inside a path are not treated as another template. No session/directory means an unavailable value, not process.cwd() guessed as the user's directory. The original prompt/suffix bytes remain untouched. The validation logic exists as **one copy each** in `preset/prompt-tool.mjs` and `index.mjs`, and the duplication is intentional: this way the preset does not have to depend on the package's install location, and the editor's path stays configurable. ## 8. Debugging methods (useful next time this plugin is changed) - **Confirm a row made it into the composition tree**: `dsh --profile

--dump-config | grep ` - **Confirm a client module can be discovered**: mount a dynamic Cordis plugin and look for your own package id in `ctx.get('clientModules').graph().entries`. An entry = composition succeeded; no entry = discovery failed. - **Confirm the browser half actually ran**: `console.log` inside `apply`, and watch the browser Console. - **`ctx.loader.entries()` contains only root-plane entries**, so do not use it to verify a preset's rows. - After changing the contents of `client.js`, **no restart is needed**: HMR rebuilds the client graph (see §10 below); only changes to the host half need a restart. ## 9. One-sentence summary > The preset manages **prompt content** (re-evaluated on every step); the profile bundle manages **the editing UI** (discovered during startup assembly). > The only contract between the two is **the same file path**. ## 10. Client hot reload is measured to work (no restart needed to change the UI) `dsh-client-hmr`'s host half **stat-polls** every client plugin's bundle file every `pollIntervalMs` (500ms by default); as soon as `mtimeMs`/`size` changes it calls `clientModules.rebuilt(id)` → rebuilds the client graph → pushes it to the page over the `/plugins/events` SSE → the browser half does `reload(id, rev)`: ``` modLoader.invalidate(id, rev) await modLoader.prefetch(id) await tearDownEntryFiber(entry) removeOwnedStyles(id) // remove that plugin's own