Compare commits

...
Author SHA1 Message Date
dsh-relay 0af110766d ci: pick the webserver bind address from the installed schema
Sync upstream and build images / sync-and-build (push) Successful in 38m42s
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.
2026-10-13 21:09:56 -04:00
dsh-relay 8363be933d Bind the web UI to the container's own address: dsh 0.2.1+ rejects 0.0.0.0
Sync upstream and build images / sync-and-build (push) Successful in 24m26s
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.
2026-10-13 20:45:00 -04:00
mward4 128ca9d8b3 ci: only a never-published version can fail the sync run
Sync upstream and build images / sync-and-build (push) Successful in 16m13s
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.
2026-09-28 14:33:27 -04:00
dsh agent c398febf0f Default the workspace doctor to repair
Sync upstream and build images / sync-and-build (push) Successful in 8m20s
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.
2026-09-17 22:26:21 -04:00
dsh agent 3849709cc2 Fix the doctor's invocation path; assert the boot log in CI
Sync upstream and build images / sync-and-build (push) Successful in 8m26s
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.
2026-09-17 22:21:58 -04:00
5 changed files with 215 additions and 48 deletions

No files matched your search

+65 -16
View File
@@ -13,8 +13,16 @@ name: Sync upstream and build images
# 4. Publish one Gitea release per built version on the mirrored upstream # 4. Publish one Gitea release per built version on the mirrored upstream
# tag, titled like upstream, carrying upstream's own notes plus a short # tag, titled like upstream, carrying upstream's own notes plus a short
# image footer. Updated in place when the image is rebuilt. # image footer. Updated in place when the image is rebuilt.
# 5. A version that fails to build or push fails the run: a missed upstream # 5. A version that fails to build or push fails the run, but only when the
# release must be visible, not a warning in a green run. # registry has never had it: a missed upstream release must be visible,
# not a warning in a green run. A *refresh* rebuild — the registry
# already has an image for that version and is only being moved onto a
# newer pnpm/base/recipe — is best-effort: it warns, leaves the existing
# image alone, and cannot turn the run red. Old versions cannot always
# be rebuilt any more, because their floating dependency ranges now
# resolve to packages that have moved past them, and a doomed 0.1.x
# refresh must not hide the 0.2.x release that did ship. Every failure
# stays listed in the run either way.
# #
# Images are pushed with regctl, in 50 MB chunks. The registry sits behind # Images are pushed with regctl, in 50 MB chunks. The registry sits behind
# Cloudflare, which rejects any request body over 100 MB; `docker push` sends a # Cloudflare, which rejects any request body over 100 MB; `docker push` sends a
@@ -143,23 +151,35 @@ jobs:
done < <(sort -V /tmp/upstream-versions) done < <(sort -V /tmp/upstream-versions)
echo "candidates:$CANDIDATES" echo "candidates:$CANDIDATES"
BUILDS="" # Two kinds of build, and they do not matter equally. NEW is a
# version the registry has never had — an upstream release that has
# not shipped here yet, which is the whole point of this job.
# REFRESH is an already-published version being moved onto newer
# toolchain. 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.
NEW=""
REFRESH=""
for V in $CANDIDATES; do for V in $CANDIDATES; do
if ! tag_exists "$V"; then if ! tag_exists "$V"; then
echo "$V: missing -> build"; BUILDS="$BUILDS $V" echo "$V: missing -> build"; NEW="$NEW $V"
elif forced "$V"; then elif forced "$V"; then
echo "$V: rebuild requested"; BUILDS="$BUILDS $V" echo "$V: rebuild requested"; REFRESH="$REFRESH $V"
else else
HAVE=$(tag_toolchain "$V") HAVE=$(tag_toolchain "$V")
if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; BUILDS="$BUILDS $V"; fi if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; REFRESH="$REFRESH $V"; fi
fi fi
done done
BUILDS="$NEW $REFRESH"
# Newest release candidate = what :latest points at. # Newest release candidate = what :latest points at.
CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1) CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1)
echo "new (must ship):$NEW"
echo "refresh (best-effort):$REFRESH"
echo "planned builds:$BUILDS" echo "planned builds:$BUILDS"
echo "current (:latest): $CURRENT" echo "current (:latest): $CURRENT"
{ {
echo "builds=${BUILDS}" echo "builds=${BUILDS}"
echo "new=${NEW}"
echo "current=${CURRENT}" echo "current=${CURRENT}"
echo "pnpm=${PNPM_LATEST}" echo "pnpm=${PNPM_LATEST}"
echo "base_digest=${BASE_DIGEST}" echo "base_digest=${BASE_DIGEST}"
@@ -174,7 +194,6 @@ jobs:
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }} PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }} BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }} RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
CURRENT: ${{ steps.plan.outputs.current }}
CI_RUN: ${{ github.run_number }} CI_RUN: ${{ github.run_number }}
run: | run: |
set -u set -u
@@ -215,6 +234,21 @@ jobs:
DOCTOR_RC=0 DOCTOR_RC=0
docker exec "$SMOKE" dsh-workspace-doctor.mjs --check > /dev/null || DOCTOR_RC=$? docker exec "$SMOKE" dsh-workspace-doctor.mjs --check > /dev/null || DOCTOR_RC=$?
[ "$DOCTOR_RC" -le 1 ] [ "$DOCTOR_RC" -le 1 ]
# ...and the entrypoint must actually invoke it. The doctor prints a
# "dsh-workspace-doctor:" line when it runs; the entrypoint logs a
# "workspace doctor:" line only when it could not. A direct
# `docker exec` above sails past a wrong path or a lost exec bit, so
# the boot log, not the binary, is what proves the wiring.
if ! docker logs "$SMOKE" 2>&1 | grep -q "^dsh-workspace-doctor:"; then
echo "the workspace doctor did not run at boot"
docker logs "$SMOKE" | tail -30
exit 1
fi
if docker logs "$SMOKE" 2>&1 | grep -q "workspace doctor:"; then
echo "the entrypoint could not run the workspace doctor"
docker logs "$SMOKE" | tail -30
exit 1
fi
OK="" OK=""
for i in $(seq 1 30); do for i in $(seq 1 30); do
if docker exec "$SMOKE" node -e ' if docker exec "$SMOKE" node -e '
@@ -247,6 +281,7 @@ jobs:
) )
FAILED="" FAILED=""
MISSING=""
for V in ${{ steps.plan.outputs.builds }}; do for V in ${{ steps.plan.outputs.builds }}; do
if build_one "$V"; then if build_one "$V"; then
echo "::notice::built ${IMAGE}:${V}" echo "::notice::built ${IMAGE}:${V}"
@@ -254,17 +289,24 @@ jobs:
docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true
echo "::warning::${V} did not build or push; any existing image for it is left untouched" echo "::warning::${V} did not build or push; any existing image for it is left untouched"
FAILED="$FAILED $V" FAILED="$FAILED $V"
# Missing from the registry AND failed to build = an upstream
# release we failed to ship. Record it for the final gate.
case " ${{ steps.plan.outputs.new }} " in *" $V "*) MISSING="$MISSING $V";; esac
fi fi
done done
echo "failed=${FAILED}" >> "$GITHUB_OUTPUT" echo "failed=${FAILED}" >> "$GITHUB_OUTPUT"
if [ -n "$FAILED" ]; then echo "missing=${MISSING}" >> "$GITHUB_OUTPUT"
echo "failed versions:$FAILED" # Deliberately no exit here. Steps 5-7 still have to run so the
case " $FAILED " in *" $CURRENT "*) echo "::error::the current version (${CURRENT}) failed to build"; exit 1;; esac # releases for every version that did ship are in place even when
fi # this run ends red; the single red/green decision belongs to the
# last step. Aborting here is what used to leave a new release
# unpublished.
[ -z "$FAILED" ] || echo "failed versions:$FAILED"
# `:latest` = the newest release-candidate version. A manifest copy # `:latest` = the newest release-candidate version. A manifest copy
# through the registry API: identical digest, nothing rebuilt. # through the registry API: identical digest, nothing rebuilt.
- name: Point :latest at the current version - name: Point :latest at the current version
id: latest
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
@@ -275,7 +317,9 @@ jobs:
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; } digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; }
WANT=$(digest_of "$CURRENT") WANT=$(digest_of "$CURRENT")
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry"; exit 1; } # Do not exit non-zero here either: that would skip both the prune
# and the release publish. Record the miss, let the gate decide.
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry, so :latest cannot be moved"; echo "latest_failed=1" >> "$GITHUB_OUTPUT"; exit 0; }
HAVE=$(digest_of latest || true) HAVE=$(digest_of latest || true)
echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}" echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}"
[ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; } [ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; }
@@ -334,9 +378,14 @@ jobs:
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js "$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js
# Last, so :latest and the releases for everything that did ship are # Last, so :latest and the releases for everything that did ship are
# already in place when the run turns red. # already in place before the run turns red — and the only thing that
- name: Fail the run if a version did not ship # turns it red is a version this registry has never published. Refreshes
if: steps.build.outputs.failed != '' # of versions we already ship stay warnings.
- name: Fail the run if a new version did not ship
if: steps.build.outputs.missing != '' || steps.latest.outputs.latest_failed != ''
run: | run: |
echo "::error::these versions did not build or push:${{ steps.build.outputs.failed }}" [ -z "${{ steps.build.outputs.missing }}" ] || \
echo "::error::never-published versions did not build or push:${{ steps.build.outputs.missing }}"
[ "${{ steps.latest.outputs.latest_failed }}" != "1" ] || \
echo "::error::${{ steps.plan.outputs.current }} has no image in the registry, so :latest was not moved"
exit 1 exit 1
+11 -3
View File
@@ -6,9 +6,13 @@
# image installs that exact pinned release from registry.npmjs.org, so its # image installs that exact pinned release from registry.npmjs.org, so its
# provenance chain is: upstream tag -> upstream npm release -> this Dockerfile. # provenance chain is: upstream tag -> upstream npm release -> this Dockerfile.
# #
# No upstream code is patched. The all-interfaces bind goes through upstream's # No upstream code is patched. The bind address comes from the `--patch`
# supported `--patch` launcher layer (/opt/deepseek-harness/web.cordis.patch.yml); # launcher layer (/opt/deepseek-harness/web.cordis.patch.yml), which the
# the UI is only reachable through ports you explicitly publish on the host. # entrypoint renders at startup: dsh 0.2.1-alpha.2+ rejects the unspecified
# address in the webserver schema and gets the container's own IPv4 instead,
# while releases whose schema still only accepts 127.0.0.1 or 0.0.0.0 keep
# 0.0.0.0. The UI is reachable only through ports you explicitly publish on the
# host.
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
@@ -62,11 +66,15 @@ RUN set -eux; \
# installs) lives inside the mounted state volume, never the rootfs. # installs) lives inside the mounted state volume, never the rootfs.
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to # PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
# them at startup. Unraid's share default is 99:100. # them at startup. Unraid's share default is 99:100.
# DSH_WORKSPACE_DOCTOR: the entrypoint's workspace-grouping step. `repair` is
# the default so an update heals the projects whose directories the update
# erased; `warn` reports without repairing, `off` is silent.
ENV DSH_HOME=/home/node/.dsh \ ENV DSH_HOME=/home/node/.dsh \
PNPM_HOME=/home/node/.dsh/.pnpm \ PNPM_HOME=/home/node/.dsh/.pnpm \
XDG_CACHE_HOME=/home/node/.dsh/.cache \ XDG_CACHE_HOME=/home/node/.dsh/.cache \
XDG_STATE_HOME=/home/node/.dsh/.state \ XDG_STATE_HOME=/home/node/.dsh/.state \
XDG_CONFIG_HOME=/home/node/.dsh/.config \ XDG_CONFIG_HOME=/home/node/.dsh/.config \
DSH_WORKSPACE_DOCTOR=repair \
PUID=1000 \ PUID=1000 \
PGID=1000 PGID=1000
+26 -9
View File
@@ -123,10 +123,11 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
- **Workspace grouping is checked before every start.** A project directory outside the - **Workspace grouping is checked before every start.** A project directory outside the
mounted `/workspace` and state volume does not survive a container update, and dsh joins mounted `/workspace` and state volume does not survive a container update, and dsh joins
a session to its project only at creation — so those chats collect under "Ungrouped" and a session to its project only at creation — so those chats collect under "Ungrouped" and
stay there. The entrypoint reports every such project and every recoverable chat, and stay there. Before every start the entrypoint recreates the missing project directories
with `DSH_WORKSPACE_DOCTOR=repair` recreates the missing directories and re-accounts the and re-accounts the orphaned chats, so an update heals itself with no configuration.
orphaned sessions at startup. Session logs are never touched; the workspace registry is Session logs are never touched; the workspace registry is backed up first.
backed up first. See [Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update). `DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent. See
[Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
#### Operations #### Operations
- **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly, - **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly,
@@ -229,10 +230,16 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
<Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config> <Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config> <Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config> <Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
<Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. warn (default in the image) only reports; repair recreates project directories that the container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config> <Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. repair (the image default, so leaving this alone is fine) recreates project directories a container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); warn only reports; off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config>
</Container> </Container>
``` ```
An **existing** container keeps the fields it was created with: Unraid does not add a
variable a template gained later. That is fine here — the image default is already
`repair`, so the Workspace Doctor row above only matters if you want to differ from it
(`warn` reports without repairing, `off` is silent), set by hand under Docker → Edit →
*Add another Path, Port, Variable, Label or Device* → Variable.
Behind a Cloudflare Tunnel, add the public hostname as a `--trusted-host` and have the Behind a Cloudflare Tunnel, add the public hostname as a `--trusted-host` and have the
tunnel rewrite `Host` to that name (and strip `Origin`) so the fence sees a name it tunnel rewrite `Host` to that name (and strip `Origin`) so the fence sees a name it
trusts. trusts.
@@ -250,7 +257,7 @@ trusts.
| `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. | | `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. |
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless `DSH_CHOWN_WORKSPACE=1`. | | `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless `DSH_CHOWN_WORKSPACE=1`. |
| `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` | | `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` |
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`warn` default, `repair` fixes lost grouping, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume | | Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`repair` default — reports and repairs lost grouping at every start; `warn` reports only, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume |
| Labels | `dev.milesward.dsh-version`, `.pnpm-version`, `.base-digest`, `.recipe-hash` — what CI's freshness check reads | | Labels | `dev.milesward.dsh-version`, `.pnpm-version`, `.base-digest`, `.recipe-hash` — what CI's freshness check reads |
Four rules that are easy to get wrong: Four rules that are easy to get wrong:
@@ -322,15 +329,25 @@ runs once per state volume, on the first project, and never again.) Two conseque
chat into it. The old chats stay under "Ungrouped". chat into it. The old chats stay under "Ungrouped".
Before every start the entrypoint reports this — each project with its storage, each Before every start the entrypoint reports this — each project with its storage, each
missing directory, every chat it could recover — and with `DSH_WORKSPACE_DOCTOR=repair` missing directory, every chat it could recover — and repairs it, with no configuration to
it fixes it at startup: set. `DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent:
| | | | | |
|---|---| |---|---|
| Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists | | Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists |
| Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in | | Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in |
| Before writing | copies the workspace registry to `workspace.json.bak-<timestamp>`; the write itself is atomic and in dsh's own format | | Before writing | copies the workspace registry to `workspace.json.bak-<timestamp>`; the write itself is atomic and in dsh's own format |
| By hand | `docker exec -u 1000:1000 <container> dsh-workspace-doctor.mjs --check` (add `--all --dry-run` to preview a repair) | | By hand | report with `docker exec <container> dsh-workspace-doctor.mjs --check`; repair with the one-off container below |
To repair without waiting for a container start, stop the container first so nothing races
the registry write, then run the doctor against the state volume directly (`--entrypoint`
bypasses dsh, and the image's `PUID`/`PGID` are applied to what it writes):
```sh
docker run --rm --entrypoint /usr/local/bin/dsh-workspace-doctor.mjs \
-v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
gitea.milesward.dev/mward4/deepseek-harness:latest --all
```
The recreated directory is empty: this restores the *grouping*, not the files — those lived The recreated directory is empty: this restores the *grouping*, not the files — those lived
in the container and nothing here can bring them back. Keep project data under `/workspace` in the container and nothing here can bring them back. Keep project data under `/workspace`
Regular → Executable
+99 -14
View File
@@ -38,6 +38,71 @@ else
-*) set -- web --patch "$PATCH" --no-open "$@" ;; -*) set -- web --patch "$PATCH" --no-open "$@" ;;
esac esac
fi fi
# 0.2.1-alpha.2 replaced the webserver's old `host: "127.0.0.1" | "0.0.0.0"`
# union with a config transform that rejects the unspecified address outright —
# "host … is an unspecified (wildcard) address, which is not supported … bind
# one concrete IPv4 or IPv6 address of a local interface instead" — and a
# schema-level check cannot be opted out of from the launcher. The shipped patch
# is therefore a template: render it onto this container's own address and hand
# dsh the rendered copy, so a port published on the host still reaches the UI
# while the socket binds one concrete interface instead of every interface.
# Releases whose schema still accepts the wildcard keep 0.0.0.0, so old images
# rebuild exactly as before. DSH_WEB_HOST overrides the address.
if [ -r "$PATCH" ]; then
BIND_HOST="${DSH_WEB_HOST:-}"
if [ -z "$BIND_HOST" ]; then
# Detect the installed webserver schema rather than guessing from a
# version string.
dsh_root="${DSH_INSTALL_ROOT:-/usr/local/lib/node_modules/@deepseek-ai/dsh}"
ws="$dsh_root/node_modules/@deepseek-ai/dsh-host-webserver/lib/index.js"
if [ -f "$ws" ] && grep -qs 'unspecified (wildcard) address' "$ws"; then
wildcard_rejected=1
elif [ ! -f "$ws" ] && grep -rqs 'unspecified (wildcard) address' "$dsh_root" 2>/dev/null; then
wildcard_rejected=1
else
wildcard_rejected=0
fi
if [ "$wildcard_rejected" = 1 ]; then
# Same discovery dsh's own boot smoke test uses: first non-internal IPv4.
BIND_HOST="$(node -e 'const os=require("os");const n=Object.values(os.networkInterfaces()).flat().find(i=>i&&i.family==="IPv4"&&!i.internal);process.stdout.write(n?n.address:"")' 2>/dev/null || true)"
[ -n "$BIND_HOST" ] || BIND_HOST=127.0.0.1
else
BIND_HOST=0.0.0.0
fi
fi
render_patch() {
sed -e "s|__DSH_BIND_HOST__|${BIND_HOST}|g" \
-e "s|^\([[:space:]]*host:[[:space:]]*\)0\.0\.0\.0[[:space:]]*$|\1${BIND_HOST}|" "$PATCH"
}
mkdir -p "$STATE" 2>/dev/null || true
RUNTIME_PATCH="$STATE/web.cordis.patch.yml"
if ! render_patch > "$RUNTIME_PATCH" 2>/dev/null; then
RUNTIME_PATCH=/tmp/web.cordis.patch.yml
render_patch > "$RUNTIME_PATCH"
fi
chmod 0644 "$RUNTIME_PATCH" 2>/dev/null || true
log "webserver binds ${BIND_HOST}; runtime patch: ${RUNTIME_PATCH}"
# Point every --patch argument that names the shipped template at the
# rendered copy. Rebuilt with a rotating `set --`, so quoting survives.
expect=0
left=$#
while [ "$left" -gt 0 ]; do
arg="$1"
shift
if [ "$expect" = 1 ]; then
if [ "$arg" = "$PATCH" ]; then arg="$RUNTIME_PATCH"; fi
expect=0
elif [ "$arg" = "--patch" ]; then
expect=1
fi
set -- "$@" "$arg"
left=$((left - 1))
done
fi
# True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops # True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops
# at the first hit, so the common "already correct" case costs one stat. # at the first hit, so the common "already correct" case costs one stat.
@@ -133,21 +198,38 @@ if [ "$(id -u)" = 0 ]; then
# session is created, against the project directory's canonical path — and a # session is created, against the project directory's canonical path — and a
# directory outside the mounted /workspace and state volume does not survive # directory outside the mounted /workspace and state volume does not survive
# a container update. Those chats then collect under "Ungrouped" for good, # a container update. Those chats then collect under "Ungrouped" for good,
# because the one-time history bootstrap never runs again. Report it always; # because the one-time history bootstrap never runs again. The default is to
# recreate the directories and re-account the orphaned sessions when asked # repair: recreate the directories and re-account the orphaned sessions
# (backup first, session logs never touched). See README: # (backup first, session logs never touched), so a container update heals
# "Chats land in Ungrouped after an update". # itself with no configuration. DSH_WORKSPACE_DOCTOR=warn only reports,
case "${DSH_WORKSPACE_DOCTOR:-warn}" in # =off is silent. See README: "Chats land in Ungrouped after an update".
#
# Resolve it by name: the installed file is dsh-workspace-doctor.mjs, and a
# path spelled out here is exactly how a "not found" reaches production. The
# CI smoke test asserts this step's output appears in the boot log, so a
# rename or a wrong spelling fails the build instead of the container start.
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
case "${DSH_WORKSPACE_DOCTOR:-repair}" in
0|off|no) ;; 0|off|no) ;;
repair|all|1|yes) warn|check|report)
/usr/local/bin/dsh-workspace-doctor --all \ if [ -n "$DOCTOR" ]; then
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged" "$DOCTOR" --check \
# The doctor runs as root here; hand what it wrote back to the runtime user. || log "workspace doctor: see the report above; DSH_WORKSPACE_DOCTOR defaults to repair, so this start was told not to fix it"
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT checked. Update the image."
fi
;; ;;
*) *)
/usr/local/bin/dsh-workspace-doctor --check \ if [ -n "$DOCTOR" ]; then
|| log "workspace doctor: see the report above - set DSH_WORKSPACE_DOCTOR=repair to fix it at startup" "$DOCTOR" --all \
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
# The doctor runs as root here; hand what it wrote back to the runtime user.
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT repaired. Update the image."
fi
;; ;;
esac esac
@@ -163,6 +245,9 @@ if [ ! -w "$STATE" ]; then
log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>" log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>"
exit 1 exit 1
fi fi
/usr/local/bin/dsh-workspace-doctor --check \ DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)" if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
fi
exec dsh "$@" exec dsh "$@"
+14 -6
View File
@@ -1,10 +1,18 @@
# Launcher patch layer for containerized `dsh web` (passed via --patch). # Launcher patch layer for containerized `dsh web` (passed via --patch).
# Upstream's CLI refuses `--host 0.0.0.0` (deliberate friction: the web UI can #
# execute code), but the webserver plugin's schema supports exactly # The host is a placeholder, not a value. From 0.2.1-alpha.2 the webserver
# '127.0.0.1' | '0.0.0.0'; this file is the supported opt-in. Inside a # schema rejects the unspecified address outright ("host … is an unspecified
# container an all-interfaces bind is only reachable through ports you # (wildcard) address, which is not supported … bind one concrete IPv4 or IPv6
# explicitly publish on the Docker host. # address of a local interface instead") and that check is a config transform,
# so no launcher flag can opt out of it; a static 0.0.0.0 — which the earlier
# `"127.0.0.1" | "0.0.0.0"` schema accepted — now fails the boot smoke test.
#
# The image entrypoint renders this file at startup: onto the container's own
# IPv4 address when the installed schema rejects the wildcard, otherwise back
# to 0.0.0.0 so older images build unchanged. A port published on the Docker
# host still reaches the UI either way, because the published port is forwarded
# to exactly that address. Set DSH_WEB_HOST to pin a different address.
- id: webserver - id: webserver
config: config:
host: 0.0.0.0 host: __DSH_BIND_HOST__
port: 3080 port: 3080