# dsh-plugin-market

- id: dsh-plugin-market
- version: 0.2.3
- developer: DeepSeek Harness
- packageName: dsh-plugin-market
- loaderId: dsh-plugin-market
- sha256: bfe43adf437d966bb0b94af3238f584b047f5f365a4945c9e3d911092a921cb8
- byteSize: 258261
- publishedAt: 2026-08-25T06:20:22.976Z
- updatedAt: 2026-09-21T17:16:40.504Z
- releasedAt: 2026-09-21T17:16:40.504Z
- html: https://addon.dshdo.com/plugins/dsh-plugin-market
- json: https://addon.dshdo.com/v1/plugins/dsh-plugin-market
- markdown: https://addon.dshdo.com/plugins/dsh-plugin-market.md
- tarball: https://addon.dshdo.com/v1/plugins/dsh-plugin-market/versions/0.2.3/tarball

## Install

```
curl -fsSL https://addon.dshdo.com/install-plugin-market.sh | sh
# then install catalog id dsh-plugin-market in /plugin-market/ and enable it
```

## Description

插件市场：浏览、安装、卸载、更新、启停插件。从独立页面 /plugin-market 管理本机与云端目录。

## Changelog

(none)

## Versions

- 0.2.3 (2026-09-21T17:16:40.504Z)
- 0.2.2 (2026-09-08T12:03:10.501Z): 0.2.2：完整注册回滚（含 registration rollback 恢复 patch/dep/link）；Config 仅 Cordis 装配且 settings 读取失败大声报错；git 通用流式输出；非 ENOENT unlink 错误不再吞掉。内置冻结资产 market-6t1ytd2v.js / market-rfzjgc40.css。
- 0.2.1 (2026-08-25T06:20:22.976Z): bundled dshdo 设备侧栏改走 tapIndex；安装脚本写入正在运行的 DSH_HOME。

## README

# dsh-plugin-market

Local plugin market for DeepSeek Harness. Open `/plugin-market/` on the same `dsh web` process. On bundled dshdo devices, install into the live `$DSH_HOME` (often `~/.local/share/dshdo/dsh/home`), not `~/.dsh`.

- Install from `~/dsh-plugins` or the cloud catalog at https://addon.dshdo.com
- Install registers the plugin with the dsh plugin manager, which owns the profile layer and the enable/disable/remove lifecycle.
- UI chrome is Chinese/English (`中文` / `English`). Plugin descriptions stay the published string.
- Hash routes: `#/plugin/<id>` (installed / local shelf) and `#/catalog/<id>` (cloud detail).
- Cloud detail also has agent URLs: HTML `/plugins/<id>`, Markdown `/plugins/<id>.md`, JSON `/v1/plugins/<id>`.

```
curl -fsSL https://addon.dshdo.com/install-plugin-market.sh | sh
```

Then restart `dsh web` and open `http://127.0.0.1:3080/plugin-market/`.

## Security model (v0.2.2)

Every request to `/plugin-market` — API and static alike — must satisfy the
loopback trust fence (`lib/trust.mjs`), a standalone copy of the harness
`/api` fence semantics:

- the socket peer must be loopback (IPv4 `127/8`, `::1`, or IPv4-mapped);
- the `Host` header must be a bare loopback authority (DNS-rebinding defense);
- `Sec-Fetch-Site: cross-site` is refused; an attached `Origin` must be a
  serialized origin equal to the Host authority exactly — scheme, host, and
  port — where the scheme is the one the request actually arrived on
  (`req.socket.encrypted`; `http:` on the plain-HTTP loopback server, `https:`
  behind TLS), never assumed; the literal `null` origin is refused;
- no CORS headers are emitted. A same-origin page on this host may drive the
  market; cross-site browser pages cannot. Remote LAN browsers are handled by
  the authenticated access-gateway LAN front, which terminates them and
  forwards to loopback (Origin/Sec-Fetch stripped, Host rewritten to the
  loopback authority), so such forwards pass the fence without weakening it
  for direct requests.

Management mutations additionally require a strict `application/json` body of
at most 64 KiB. Tarball installs never run a system `tar`: the archive is
parsed by the market's own reader, which refuses symlink/hardlink/device
members and path traversal and enforces per-file / total / entry-count caps
before extraction into a unique staging directory.

## Lifecycle ownership and restart semantics

The dsh plugin manager (`ctx.pluginManager`) is the single lifecycle
authority. `enable` and `disable` call
`setBundleEnabled(<package name>, true|false)`, `uninstall` calls
`removeBundle(<package name>)`, and a catalog install hands the freshly staged
directory to `installBundle(stagedDir, { enabled: true })`. The market never
writes `cordis.patch.yml`, the profile manifest, or profile symlinks itself,
so a change's profile result is exactly the manager's `ChangeResult`, returned
verbatim (`application`, `stage`, `error`, `warnings`, `packageResult`,
`bundle`). A resolved refusal (`application: 'failed'`, e.g. `not-bundle`,
`not-removable`, `management-required`) is returned as HTTP 200 with
`ok: true` and the failure visible in `value`; the page inspects
`application`/`error` and reports the failure instead of assuming success. A
manager rejection — the service missing or throwing — propagates as the
handler's HTTP error (503 when unavailable).

The market keeps only the fences the manager cannot own: it refuses to
disable or uninstall itself (403), and `update` refuses a plugin whose
running copy resolves inside the harness checkout (`builtin-resolved`) with
409 before any catalog work. Every other protection — builtin, removable,
in-use, management-required — is the manager's decision and is surfaced as its
`ChangeResult`.

Responses carry `restartRequired` when a change cannot take effect until the
`dsh web` process restarts, and the plugin's list row then keeps the flag
until the process restarts (the flag is per-plugin and process-lifetime, never
read from the harness updater's `update-status.json`). Updates to
catalog-installed plugins are gated inside `catalog.mjs` against the installed
tree's full-payload hash (treeHashVersion 2): when the tree was edited after
install — or the record predates/omits a trusted hash — the update requires an
explicit `consent: true` field (409 `needs-consent` otherwise). An update
swaps the shelf directory in place and leaves the profile registration the
manager owns alone; if replacing the tree fails and restoring the old
directory also fails, the error response carries the retained pre-update
`backupPath` (outside the shelf) and says so in the message. List rows report
runtime fields (`runtimeVersion` / `runtimePath` / `runtimeSource`) as `null`
unless a loader record attests a live module (cordis keeps only the executable
callback per plugin, so version and path are never inferred from disk);
resolved identity is reported under `resolvedVersion` (null when the resolved
copy's manifest is unreadable — never the shelf row's own claim) /
`resolvedModulePath` / `moduleSource`. `enabled` and `installed` come from the
manager's `listBundles()` record, joined with the local and catalog facts.

A manager refusal or cancellation is returned verbatim; the market performs no
rollback of its own. Child diagnostics from a local-git `git pull` are never
returned over HTTP. Local Git updates are fast-forward only and do not provide
the catalog's directory-snapshot rollback.

API contracts: see [docs/API.md](docs/API.md). Tests: `node --test "tests/*.test.mjs"`.

## Request lifecycle and plugin config

The plugin exposes five Config fields, split by when a change applies:

- `enabled` is a Cordis-only mount control, not a live setting. Invalid stored market settings prevent mounting.
- Live through the `dsh-plugin-market` settings namespace: `catalogUrl` and `requestTimeoutMs` (validated integer 1..2147483647,
  default 30000). `requestTimeoutMs` is the overall deadline each request
  runs under.
- Restart-applied, captured at apply (they configure the runner object, so a
  live write cannot re-bind it): `maxOutputBytes` (1..16777216, default
  1048576) caps each of a child command's stdout/stderr streams, and
  `graceMs` (1..60000, default 1000) bounds the terminate escalation. Change
  them in cordis.yml and restart the process; the settings namespace never
  exposes them, so no UI claims a live effect.

The handler gives every request one
`AbortSignal`: body reads, catalog fetches, and catalog install/update
downloads and hash checks bind to it, and a mutation that queued behind other
work re-checks the signal once it acquires its lock — so a request that
exceeds the deadline (408) or loses its handler during unload (503) never
keeps running and never commits state it queued for.

The handler owns every request it accepts. On unload, index.js unregisters
the `/plugin-market` route first (the server stops dispatching to it), then
disposes the handler: in-flight requests are aborted with a 503 reason and
awaited until they settle, and requests that race in after disposal are
refused with 503. Disposal is idempotent — repeated disposer calls await the
same drain, and an unload can never return while an old HTTP mutation is still
touching torn-down state. The deadline/disposal is an abort *request*, not a
forced completion: a write already past its commit point (started but not yet
released settings update, queued settlement) is joined to its real finish —
dispose waits for it and never interrupts started work (held-write probe in
`tests/serve-lifecycle.test.mjs`).

The one command the plugin runs — a local-git update's `git pull --ff-only` —
goes through the injected managed subprocess service (`ctx.subprocess`) via a
per-apply runner (`lib/run.mjs`), never a blocking native child: the run is
bounded by the request deadline, cancelled when the request aborts or the
handler is disposed, and awaited by the plugin's teardown. The child
environment forwards only `DSH_HOME` (`dshChildEnv`); the provider scrubs
ambient `DSH_*` names, and the plugin directory is the run's cwd — test
controls live in the fixture's fake executable, never as `DSH_*` env names in
a production child. A failed pull is reported generically (safe exit status
only); the child's raw stdout/stderr is never echoed into the HTTP error or
logs.

`apply` must be an arrow function (`export const apply = ...`), never a
constructible `export function apply`: cordis runs a constructible callback
through `new` and discards its return value, so a plain function's disposer
would never be collected and unloading would leave the route mounted. The
real-fiber probe in `tests/fiber-dispose.test.mjs` boots the plugin through
the actual vendored Loader and proves both the arrow-form drain and the
constructible-form failure mode.

