The 0.2.1-alpha.2 fix rendered `host` onto the container's own address
unconditionally. Every earlier release still validates it against the union of
the two literals 127.0.0.1 and 0.0.0.0, so those images now die in the boot
smoke test with
@deepseek-ai/dsh-host-webserver: ValidationError: invalid config:
- $.host expected "127.0.0.1" | "0.0.0.0" but got "172.17.0.22" (at host)
and their refresh rebuilds -- which the recipe-hash change re-queued anyway --
can no longer succeed. Probe the installed webserver schema instead: where it
rejects the unspecified address render the container's own IPv4, otherwise keep
0.0.0.0 so older versions build exactly as they did before. The probe is
overridable through DSH_INSTALL_ROOT so both branches are testable.
dsh 0.2.1-alpha.2 validates `webserver.host` in a schemastery transform and
throws on the unspecified address ("is an unspecified (wildcard) address,
which is not supported: binding every interface would expose remote code
execution to the network; bind one concrete IPv4 or IPv6 address of a local
interface instead"). The check is config-level, so the --patch opt-in that
carried 0.0.0.0 through the 0.1.x schema no longer works, and every build of
0.2.1-alpha.2 has failed the boot smoke test before the image could be pushed
(the tag exists, so the release page has no image and no notes).
The shipped patch becomes a template; docker-entrypoint.sh renders it onto the
container's first non-internal IPv4 (the same address the smoke test probes)
and passes the rendered copy to dsh, so a port published on the Docker host
still reaches the UI while the socket binds one concrete interface. Set
DSH_WEB_HOST to override the address. The substitution also accepts the old
literal `host: 0.0.0.0` template, so either file can land first.
Runs have been red since 2026-09-19 even though every release shipped: the
plan step re-plans the oldest 11 versions as "stale" on every run (pnpm and
the node base image moved on), and those versions can no longer be rebuilt at
all — their floating dependency ranges now resolve to @deepseek-ai packages
that have moved past them, so they fail the boot smoke test ~67s in. Step 8
then failed the run for them, and every 6-hour run went red for ~13 minutes of
builds that could never succeed. A green run meant nothing, and a red one no
longer pointed at anything real.
Split the plan into two kinds of build and treat them differently:
NEW - the registry has no image for this version. This is an upstream
release that has not shipped here yet, which is the whole job.
REFRESH - the registry already has the image; it is only being moved onto a
newer pnpm/base/recipe. Best-effort.
* Plan NEW first so a new release lands in the first minutes of the run
instead of queueing behind refreshes that may all be unbuildable.
* A failed NEW version is recorded in a `missing` output and later reported
by the final gate; a failed REFRESH stays a warning and keeps its image.
* The final gate now fails only on `missing` or on a current version with no
image in the registry — a missed upstream release is still visible and red.
Also removes two early aborts that silently skipped the prune and the release
publish whenever the current version failed to build: step 4 no longer exits
after a failed build, and the :latest step records its miss instead of
exiting. Publishing now always happens, so the releases for the versions that
did ship are in place even when the run legitimately ends red.
Nothing repaired on a plain container update: the image defaulted to
`warn`, which reports lost grouping and stops there, so a project
directory an update erased stayed erased unless the operator set
DSH_WORKSPACE_DOCTOR=repair — a field an existing Unraid container never
receives, because Unraid does not add variables a template gained later.
Bake DSH_WORKSPACE_DOCTOR=repair into the image and make that the
entrypoint's fallback, so an update heals itself with no configuration:
missing project directories are recreated and the chats orphaned with
them are re-accounted at startup, with the registry backed up first and
session logs untouched. `warn` reports without repairing, `off` is
silent, and an unrecognised value repairs rather than silently doing
nothing.
README: the doctor is described as the default with the variable as an
opt-out, and the Unraid note now says an existing container needs no new
field.
The entrypoint called /usr/local/bin/dsh-workspace-doctor while the
Dockerfile installs /usr/local/bin/dsh-workspace-doctor.mjs, so every
start logged "...: not found" and the workspace doctor never ran: no
report, no repair. The CI smoke test checked the .mjs path with a direct
`docker exec`, which cannot see a mistake in the entrypoint's own
invocation, so the build stayed green while the container was broken.
Resolve the doctor by name (`command -v dsh-workspace-doctor.mjs`) in
both the root and the non-root path, and log an explicit "not installed
in this image" line when it cannot be found, instead of the shell's bare
"not found". The smoke test now reads the container's boot log: it
requires the doctor's own "dsh-workspace-doctor:" output to appear, and
fails if the entrypoint logs its "workspace doctor:" failure line — that
covers path, exec bit and invocation together, which is the class of
mistake that shipped.
README: manual repair through a one-off container with --entrypoint (dsh
stopped, so nothing races the registry write), and the note that an
existing Unraid container does not pick up the template's new Workspace
Doctor variable on its own.
Updating the container recreates it, so anything outside the mounted
/workspace and state volume is gone. dsh joins a session to its project
when the session is created (upstream's history bootstrap runs once per
state volume and never again), and the workspace registry stores an
absolute canonical directory path — so a project directory under
/home/node, where the workspace picker opens by default, loses its chats
to "Ungrouped" permanently on the next update.
Ship dsh-workspace-doctor.mjs and run it from the entrypoint before dsh
starts: it reports every project directory that no longer resolves and
every chat recoverable from one, and with DSH_WORKSPACE_DOCTOR=repair it
recreates the missing directories and re-accounts those sessions in the
workspace registry. Session logs are never read for writing and never
modified, no project is invented, no chat is moved into a project it did
not run in, and the registry is copied to workspace.json.bak-<stamp>
before the atomic, 0600, fsynced write dsh's own JSON backend performs.
The doctor refuses to write a registry version it does not know, is
idempotent, and defaults to report-only in the image (warn) so nothing
changes unasked; the Unraid template defaults to repair.
CI: the doctor joins the recipe hash and the push path list, and the
image smoke test asserts it is installed and runs against a fresh volume
(exit 2, the tool itself failing, is the only outcome that fails a build).
`docker save` on a classic (non-containerd) daemon writes layers as raw tar,
and regctl pushes what it is given: 0.1.6-alpha.2 landed with a 517 MB
uncompressed layer, about 850 MB per pull instead of 190. Recompress the
layout with gzip before copying it to the registry.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
0.1.6-alpha.2 built and passed its smoke test on 2026-09-17, then the push
died: the registry sits behind Cloudflare, which rejects request bodies over
100 MB, and `docker push` sends a layer as one request. dsh's node_modules
layer was 78 MB compressed through 0.1.6-alpha.1 and is ~137 MB from
alpha.2 on, so every future version would have failed the same way. The run
stayed green because a non-current version only warned.
Images are now exported from the daemon and pushed with regctl (pinned
v0.11.6, checksum-verified) in 50 MB chunks; the registry accepts chunked
uploads and a 140 MB layer went through in 12 s. Any version that fails to
build or push fails the run, as a final step so :latest and the releases for
everything that did ship are in place first. Manifests left untagged by
rebuilds are pruned each run, keeping anything a tagged index still points at.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Gitea shows a repository's "latest release" on the home page and at
/releases/latest, and defines it as the newest non-pre-release. Upstream
flags every RC and alpha pre-release, and mirroring that left the home page
with no release listed although ten exist. The release for the version
:latest serves is now the one full release; every other version keeps
upstream's flag, and the flag is part of the up-to-date check so it flips
when :latest moves.
dsh 0.1.5 ships file upload under the loader id file-upload, which the
community dsh-file-upload plugin also registers. A profile that still carries
that plugin dies at boot with "duplicate loader entry id: file-upload" and the
stack trace never names the plugin. The entrypoint now compares the profile's
plugin layers (and the two hand-edited patch layers) against the installed
version's own bundle patches before every start and logs the offending plugin
with its remove command. Advisory only: the start is never blocked, and only
the profile manifest and patch YAML are read as data.
The 0.1.5+ release footer carries the caveat, and the publisher now re-renders
a release when its text changes rather than only when the digest does.
README: upgrade checklist covers this class of failure; the Highlights bullets
that still described the pre-mirror tag/release model are corrected.
Read-only probe for boot failures: collapsed log tail, the profile package.json and patch layers (key-like values redacted, credentials never read), profile-local bundle packages and the rows they insert. Tooling probe falls back to the image when the container is not running.
0.1.3-alpha.2 depends on the native module fs-ext, which needs a C toolchain
the slim runtime image does not carry; without a skip list the run would
retry it every six hours and warn each time. README notes the three
upstream tags that have no image and why.
Co-Authored-By: Claude Fable 5 <[email protected]>
Tags and releases now look like https://github.com/deepseek-ai/deepseek-harness/tags:
- The mirror step pushes upstream's dsh-v* tags verbatim (same names, commits,
dates), so the Tags page is upstream's.
- CI builds an image for every upstream version that npm published, from
MIN_VERSION (0.1.1-rc.2) up, and REBUILDS IT IN PLACE when our recipe, pnpm,
or the node base image moved since it was built. One image tag per version -
no -rN revisions. A failing older version is reported and left on its
previous image; a failing current version fails the run. A manual run can
force-rebuild specific versions or "all".
- :latest = the newest release candidate (alphas never qualify), as a manifest
copy with an identical digest.
- Releases: one per built version, ON the mirrored upstream tag, titled like
upstream (v0.1.5-rc.2), carrying upstream's English notes plus a five-line
container footer (digest, pnpm, base, build). Updated in place on rebuild,
keeping previous digests in a collapsed history. Created oldest-first so the
Releases page reads in version order.
- Removed my v*/v*-rN tags and releases; README rewritten for this model.
Co-Authored-By: Claude Fable 5 <[email protected]>
Operating model change, at the operator's request: the deployment follows
the newest release candidate rather than a hand-pinned version.
- :latest is now published, as a manifest copy (identical digest) of the
newest build of the newest RC version. It is the ONLY tag that moves;
every <version>[-rN] tag stays immutable for anyone who wants to pin.
- Freshness (pnpm / base image / recipe hash) now applies to the current
version derived from the registry, not to a Dockerfile constant, so an
image fix reaches the deployed line automatically. The `revise` dispatch
input rebuilds any older versions with the current recipe on demand.
- Smoke test now runs on a fresh named volume (root-owned, like a new Unraid
appdata dir) and asserts pid 1 is uid 1000 - the ownership fix is exercised
on every build, not just locally.
- Releases: `latest` gets no release; the "pinned deployment" callout is gone
(the README and the alias are the source of truth now).
- 0.1.1-rc.2 is deprecated; its -r2/-r49/-r52 revisions were removed
(images, tags, releases). The original tag remains as history.
- README/template: :latest in the Repository field, auto-update warning,
versions table and upgrade procedure rewritten for the new model.
Forensic workflow's trusted default -> :latest. inspect-deployment default
container name -> Deepseek.
Co-Authored-By: Claude Fable 5 <[email protected]>
A -rN build is not necessarily a toolchain refresh — -r2 changed neither pnpm
nor the base image, only the container's own tooling. Say what is certain
(same upstream release, rebuilt recipe, safe to move to) and let the labels
and the linked commit carry the specifics.
Co-Authored-By: Claude Fable 5 <[email protected]>
Publishing -r2 exposed this: a buildx multi-arch push stores an OCI index at
the tag, and both label readers assumed a single-arch manifest. Consequences
were a release body with 'pnpm ?' and — much worse — CI's freshness check
seeing the pinned image as permanently stale, which would have published a
new -rN revision every six hours forever.
Both readers now follow an index to a platform manifest (preferring amd64,
the deployment's arch) before reading the config blob, and the publisher
warns when an image carries no provenance labels at all. Verified against
both a multi-arch tag (0.1.1-rc.2-r2) and a single-arch one (0.1.5-rc.2):
the pinned build now reads as current, so no redundant rebuild.
Co-Authored-By: Claude Fable 5 <[email protected]>
Verifying the pushed -r1 image found one hole: only dsh was wrapped, so a
root 'docker exec <c> pnpm ...' created PNPM_HOME/XDG_CONFIG_HOME owned by
root inside the state volume — which the unprivileged dsh could not then use
for plugin installs (self-healing on the next restart, since the entrypoint
re-chowns, but broken until then).
The wrapper is now generic (execs the resolved target directly; dsh's bin.js
is +x with a node shebang and pnpm's bin is an ELF, so no node prefix is
needed) and is installed over dsh, pnpm, pnpx, pn and pnx. Renamed
dsh-wrapper.sh -> run-as-runtime-user.sh to match what it does.
Verified: root 'docker exec pnpm --version' + 'pnpm store path' + 'dsh plugin
add' leave zero root-owned files in the volume; 23-case matrix still green.
Ships as 0.1.1-rc.2-r2.
Co-Authored-By: Claude Fable 5 <[email protected]>
A bind mount arrives with the host's ownership, so a freshly created appdata
directory killed dsh with EACCES on mkdir /home/node/.dsh/profiles/web. Only
root inside the container can fix that, so:
- docker-entrypoint.sh starts as root, aligns the node account with PUID/PGID
(default 1000:1000, Unraid convention), takes ownership of the state volume
when needed, then setpriv's down before exec'ing dsh. The network-reachable
process is never root and no setuid binary is left behind. The workspace
mount point is made writable but its CONTENTS are left alone unless
DSH_CHOWN_WORKSPACE=1 — never silently rewrite ownership across user data.
- Dropped USER node (root is required to chown); dsh is wrapped so a root
re-execs as PUID:PGID and cannot leave
root-owned files in the volume. Wrapper resolves the real bin.js at build
time and self-tests.
- Explicit --user is honored: ownership cannot be repaired, so the entrypoint
verifies writability and exits with one actionable line instead of a Node
stack trace.
- XDG_CONFIG_HOME pinned into the volume too; pnpm was reaching for
/root/.config when HOME was unset.
- CI: labels now carry a recipe hash, and the freshness check cuts a -rN
revision when the recipe changes — so an image fix reaches the pinned
deployment without waiting for an upstream release.
- README/template: chown step removed, PUID/PGID/DSH_CHOWN_WORKSPACE
documented, template pinned to 0.1.1-rc.2-r1.
Verified with a 23-case matrix: root-owned volume self-heals and serves 200,
already-correct volume skips the chown, 99:100 + PUID/PGID works, plugin
add/remove via root docker exec leaves no root-owned files, explicit --user
still works and fails clearly when it can't, both argument forms serve.
Co-Authored-By: Claude Fable 5 <[email protected]>
The generator now receives the Dockerfile's default DSH_VERSION and calls out
that release as the pinned deployment version, so the Releases page says
which tag the Unraid template runs without hand-editing.
Co-Authored-By: Claude Fable 5 <[email protected]>
- CI plan: candidates are npm latest AND next (release candidates), plus an
optional workflow_dispatch 'version' input for any exact version. This is
what was holding back 0.1.5-rc.2 (published as next, not latest).
- Upstream dsh-v* tags are no longer mirrored into this repo (deleted the
existing ones); master stays an exact branch mirror and every release links
to its upstream tag. This repo's tags are now exclusively v<image tag>.
- Dockerfile: build-provenance labels (org.opencontainers.image.revision /
.created, dev.milesward.ci-run) so releases cite the commit and CI run that
actually built the image instead of whatever HEAD is at publish time.
- Releases: shell template replaced by .gitea/scripts/publish-releases.js,
run on the node:22 base image (no runner deps). It pulls the upstream
GitHub release's English notes, adds version-dependent caveats (launch
token from 0.1.2, Session format V3 from 0.1.5), image facts and Unraid
deploy steps. Existing v* tags re-pointed to 2d033c3 (the commit that
built those images); their releases are regenerated in this format.
- README: dynamic badges (shields Gitea release, Gitea workflow status),
0.1.5-rc.2 row, pipeline/tag-namespace docs updated.
Co-Authored-By: Claude Fable 5 <[email protected]>
- assets/: replace the white-background avatar with a transparent 512px
PNG (brand blue #4d6bfe, tight-cropped) plus a 128px header variant.
Same path, so the Unraid template icon keeps working.
- CI: every image tag in the registry now gets a v<tag> git tag on main
and a Gitea release carrying digest, dsh/pnpm/base versions, deploy
notes and the 0.1.2+ launch-token caveat. Idempotent, so it backfills
the three existing tags on the next run.
- README rewritten to match OpenPXE/Discord-Authenticator: centered brand
header, badges, contents, why/highlights, quick start, Unraid template,
image contract, versions & upgrade procedure (0.1.5 session-format V3
has no downgrade), plugins, security model, pipeline, layout.
- LICENSE: MIT for the build tooling.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Dockerfile: install pnpm (pinned via PNPM_VERSION arg) alongside dsh so
'dsh plugin --profile web add|update|remove' works in-container. Plain
'npm i -g' instead of corepack: corepack shims resolve versions at runtime
and write to a root-owned cache, breaking the uid-1000/read-only contract.
All pnpm writable dirs (store via PNPM_HOME, cache/state via XDG vars)
point into the /home/node/.dsh volume - verified zero rootfs writes.
- Dockerfile: ENTRYPOINT ['dsh'], CMD carries the patch flag AND --no-open
(a headless server must not open a browser; on 0.1.2+ that can burn the
one-time launch token). DSH_VERSION default pinned to 0.1.1-rc.2.
- CI: never publishes or moves 'latest' (deleted from the registry). Builds
only missing immutable version tags; a toolchain freshness check rebuilds
the pinned version as <ver>-rN when a newer pnpm or node base image ships.
Every build passes a smoke test before push: dsh/pnpm versions match and
the server boots with the baked patch, answering on the container's
non-loopback interface (200 pre-token, 303/401 on 0.1.2+).
- README: operating contract - pinned version rationale, PostArgs-replace-CMD
warning, 0.1.2 launch-token behavior, plugin management, lanmode
incompatibility, template pinned to :0.1.1-rc.2.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Ship /opt/deepseek-harness/web.cordis.patch.yml and pass it via upstream's
--patch launcher flag (must precede the web command's own flags) instead of
seeding DSH_HOME; entrypoint accepts template Post Arguments verbatim.
- Add /workspace (agent WORKDIR) and pin DSH_HOME=/home/node/.dsh; drop the
implicit /home/node volume so templates mount .dsh and /workspace cleanly.
- README: Unraid template (repo/support/icon/versions corrected for this
image, lanmode plugin caution), first-run chown note, DeepSeek logo.
Verified: no-args, flags-only, and full template invocations all serve the
web UI; trust fence still 403s unknown Hosts; state persists to mounted .dsh.
Co-Authored-By: Claude Fable 5 <[email protected]>
The Actions GITHUB_TOKEN can push git refs but the container registry
rejects it (run 1 failure). Also adds a manual, never-executes-the-image
forensic workflow comparing a third-party image on the runner host's
daemon against the trusted build.
Co-Authored-By: Claude Fable 5 <[email protected]>
dsh's webserver schema only accepts 127.0.0.1 or 0.0.0.0; the CLI refuses
0.0.0.0 by design. Seed $DSH_HOME/profiles/web/cordis.patch.yml (the
supported override) from the entrypoint instead. Verified: UI serves 200
through a published port, /api trust fence 403s unknown Hosts, and
--trusted-host pass-through lifts it.
Co-Authored-By: Claude Fable 5 <[email protected]>