Post-incident hardening: pnpm, pinned versions, immutable tags, no more :latest
Sync upstream and build images / sync-and-build (push) Successful in 2m4s
Sync upstream and build images / sync-and-build (push) Successful in 2m4s
- 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]>
This commit is contained in:
1 parent
768de4ac75
commit
2d033c323b
4 files changed
+246
-115
No files matched your search
@@ -1,12 +1,19 @@
|
|||||||
name: Sync upstream and build image
|
name: Sync upstream and build images
|
||||||
|
|
||||||
# Keeps this repository current with the official upstream
|
# Keeps this repository current with the official upstream
|
||||||
# (https://github.com/deepseek-ai/deepseek-harness):
|
# (https://github.com/deepseek-ai/deepseek-harness) and the registry stocked
|
||||||
|
# with IMMUTABLE version tags:
|
||||||
# 1. Mirrors upstream master and dsh-v* tags into this repo.
|
# 1. Mirrors upstream master and dsh-v* tags into this repo.
|
||||||
# 2. Reads the official npm dist-tag "latest" of @deepseek-ai/dsh.
|
# 2. Builds a new <version> tag when a new @deepseek-ai/dsh appears on npm
|
||||||
# 3. If the Gitea container registry does not yet hold an image for that
|
# and the registry lacks it.
|
||||||
# version, builds one from the pinned official npm release and pushes
|
# 3. Freshness check for the PINNED default version (ARG DSH_VERSION in the
|
||||||
# gitea.milesward.dev/mward4/deepseek-harness:<version> and :latest.
|
# Dockerfile): if a newer pnpm release or a patched node base image has
|
||||||
|
# shipped since the newest existing build of that version, builds a
|
||||||
|
# <version>-r<run> revision tag with the updated toolchain.
|
||||||
|
#
|
||||||
|
# This workflow NEVER pushes or moves `latest`, and never re-pushes an
|
||||||
|
# existing tag: every upgrade of the running deployment is a deliberate
|
||||||
|
# template edit to a new immutable tag.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -16,24 +23,19 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- Dockerfile
|
- Dockerfile
|
||||||
- docker-entrypoint.sh
|
- web.cordis.patch.yml
|
||||||
- .gitea/workflows/sync-upstream.yml
|
- .gitea/workflows/sync-upstream.yml
|
||||||
|
|
||||||
env:
|
env:
|
||||||
UPSTREAM: https://github.com/deepseek-ai/deepseek-harness.git
|
UPSTREAM: https://github.com/deepseek-ai/deepseek-harness.git
|
||||||
REGISTRY: gitea.milesward.dev
|
REGISTRY: gitea.milesward.dev
|
||||||
IMAGE: mward4/deepseek-harness
|
IMAGE: mward4/deepseek-harness
|
||||||
|
BASE_IMAGE: node:22-bookworm-slim
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-and-build:
|
sync-and-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Runner environment
|
|
||||||
run: |
|
|
||||||
uname -a
|
|
||||||
docker version || echo "NO DOCKER CLI"
|
|
||||||
node --version || true
|
|
||||||
|
|
||||||
- name: Checkout CI branch
|
- name: Checkout CI branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -50,48 +52,115 @@ jobs:
|
|||||||
git push mirror '+refs/heads/master:refs/heads/master'
|
git push mirror '+refs/heads/master:refs/heads/master'
|
||||||
git push mirror '+refs/tags/dsh-v*:refs/tags/dsh-v*'
|
git push mirror '+refs/tags/dsh-v*:refs/tags/dsh-v*'
|
||||||
|
|
||||||
- name: Resolve official release version
|
- name: Plan builds (new dsh version + pinned-version freshness)
|
||||||
id: version
|
id: plan
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
VERSION=$(npm view @deepseek-ai/dsh@latest version)
|
|
||||||
echo "official npm latest: $VERSION"
|
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Check registry for existing image
|
|
||||||
id: exists
|
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
DSH_LATEST=$(npm view @deepseek-ai/dsh@latest version)
|
||||||
-u "${{ github.repository_owner }}:${TOKEN}" \
|
PNPM_LATEST=$(npm view pnpm@latest version)
|
||||||
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
PINNED=$(sed -n 's/^ARG DSH_VERSION=//p' Dockerfile | head -1)
|
||||||
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
|
docker pull -q "$BASE_IMAGE" > /dev/null
|
||||||
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
BASE_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$BASE_IMAGE")
|
||||||
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
|
echo "dsh latest=$DSH_LATEST pinned=$PINNED pnpm=$PNPM_LATEST base=$BASE_DIGEST"
|
||||||
"https://${REGISTRY}/v2/${IMAGE}/manifests/${{ steps.version.outputs.version }}")
|
|
||||||
echo "manifest HTTP $CODE"
|
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'
|
||||||
if [ "$CODE" = "200" ]; then
|
tag_exists() {
|
||||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -u "${{ github.repository_owner }}:${TOKEN}" \
|
||||||
else
|
-H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1")" = 200 ]
|
||||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
}
|
||||||
|
# Labels of a single-arch manifest's config blob ("pnpm|base"); empty
|
||||||
|
# output (index manifests, missing labels) means "treat as stale".
|
||||||
|
tag_toolchain() {
|
||||||
|
MAN=$(curl -s -u "${{ github.repository_owner }}:${TOKEN}" \
|
||||||
|
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
|
||||||
|
"https://${REGISTRY}/v2/${IMAGE}/manifests/$1") || return 0
|
||||||
|
CFG=$(printf '%s' "$MAN" | grep -o '"digest": *"sha256:[a-f0-9]*"' | head -1 | grep -o 'sha256:[a-f0-9]*') || return 0
|
||||||
|
[ -n "$CFG" ] || return 0
|
||||||
|
BLOB=$(curl -s -u "${{ github.repository_owner }}:${TOKEN}" "https://${REGISTRY}/v2/${IMAGE}/blobs/$CFG") || return 0
|
||||||
|
P=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.pnpm-version":"[^"]*"' | cut -d'"' -f4 || true)
|
||||||
|
B=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.base-digest":"[^"]*"' | cut -d'"' -f4 || true)
|
||||||
|
echo "${P}|${B}"
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILDS=""
|
||||||
|
# 1. New official release the registry lacks -> new version tag.
|
||||||
|
if ! tag_exists "$DSH_LATEST"; then
|
||||||
|
BUILDS="$DSH_LATEST=$DSH_LATEST"
|
||||||
|
fi
|
||||||
|
# 2. Pinned version: missing entirely -> build it; otherwise compare
|
||||||
|
# the newest existing build's toolchain labels and cut a -rN
|
||||||
|
# revision when pnpm or the base image moved.
|
||||||
|
if [ "$PINNED" != "$DSH_LATEST" ] && ! tag_exists "$PINNED"; then
|
||||||
|
BUILDS="$BUILDS $PINNED=$PINNED"
|
||||||
|
elif tag_exists "$PINNED"; then
|
||||||
|
NEWEST=$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||||
|
"https://${REGISTRY}/api/v1/packages/${{ github.repository_owner }}?type=container&q=deepseek-harness&limit=100" \
|
||||||
|
| tr ',' '\n' | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' \
|
||||||
|
| grep -E "^${PINNED}(-r[0-9]+)?$" | sort -V | tail -1)
|
||||||
|
TOOL=$(tag_toolchain "$NEWEST")
|
||||||
|
echo "newest build of pinned: $NEWEST toolchain='$TOOL'"
|
||||||
|
if [ "$TOOL" != "${PNPM_LATEST}|${BASE_DIGEST}" ]; then
|
||||||
|
echo "toolchain stale (want ${PNPM_LATEST}|${BASE_DIGEST}) -> revision build"
|
||||||
|
BUILDS="$BUILDS ${PINNED}-r${{ github.run_number }}=$PINNED"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push image
|
echo "planned builds: '${BUILDS}'"
|
||||||
if: steps.exists.outputs.exists == 'false'
|
{
|
||||||
|
echo "builds=${BUILDS}"
|
||||||
|
echo "pnpm=${PNPM_LATEST}"
|
||||||
|
echo "base_digest=${BASE_DIGEST}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build, smoke-test, and push
|
||||||
|
if: steps.plan.outputs.builds != ''
|
||||||
env:
|
env:
|
||||||
# Dedicated write:package token; the ephemeral GITHUB_TOKEN can push
|
|
||||||
# git refs but is not accepted by the container registry.
|
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
|
||||||
|
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
echo "$TOKEN" | docker login "$REGISTRY" -u "${{ github.repository_owner }}" --password-stdin
|
echo "$TOKEN" | docker login "$REGISTRY" -u "${{ github.repository_owner }}" --password-stdin
|
||||||
docker build \
|
for entry in ${{ steps.plan.outputs.builds }}; do
|
||||||
--build-arg "DSH_VERSION=${VERSION}" \
|
TAG=${entry%%=*}
|
||||||
-t "${REGISTRY}/${IMAGE}:${VERSION}" \
|
VER=${entry#*=}
|
||||||
-t "${REGISTRY}/${IMAGE}:latest" \
|
REF="${REGISTRY}/${IMAGE}:${TAG}"
|
||||||
.
|
docker build \
|
||||||
docker push "${REGISTRY}/${IMAGE}:${VERSION}"
|
--build-arg "DSH_VERSION=${VER}" \
|
||||||
docker push "${REGISTRY}/${IMAGE}:latest"
|
--build-arg "PNPM_VERSION=${PNPM_VERSION}" \
|
||||||
|
--build-arg "BASE_DIGEST=${BASE_DIGEST}" \
|
||||||
|
-t "$REF" .
|
||||||
|
|
||||||
|
# Smoke: versions match, and the web server actually boots with
|
||||||
|
# the baked patch and answers HTTP on its non-loopback interface.
|
||||||
|
# 200 = pre-token versions; 303/401 = 0.1.2+ launch-token auth.
|
||||||
|
SMOKE="dsh-smoke-${{ github.run_id }}"
|
||||||
|
docker rm -f "$SMOKE" 2>/dev/null || true
|
||||||
|
docker run -d --name "$SMOKE" "$REF"
|
||||||
|
sleep 3
|
||||||
|
[ "$(docker exec "$SMOKE" dsh --version)" = "$VER" ]
|
||||||
|
[ "$(docker exec "$SMOKE" pnpm --version)" = "$PNPM_VERSION" ]
|
||||||
|
OK=""
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec "$SMOKE" node -e '
|
||||||
|
const os = require("os");
|
||||||
|
const nic = Object.values(os.networkInterfaces()).flat()
|
||||||
|
.find(i => i && i.family === "IPv4" && !i.internal);
|
||||||
|
fetch("http://" + nic.address + ":3080/").then(r => {
|
||||||
|
process.exit([200, 303, 401].includes(r.status) ? 0 : 1);
|
||||||
|
}).catch(() => process.exit(2));'
|
||||||
|
then OK=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$OK" ]; then
|
||||||
|
echo "::error::smoke test failed for $REF (server never answered on the container interface)"
|
||||||
|
docker logs "$SMOKE" | tail -50
|
||||||
|
docker rm -f "$SMOKE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker rm -f "$SMOKE"
|
||||||
|
|
||||||
|
docker push "$REF"
|
||||||
|
done
|
||||||
+38
-10
@@ -12,36 +12,64 @@
|
|||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# DSH_VERSION default is the pinned, known-good deployment version: 0.1.2+
|
||||||
|
# introduced launch-token auth and the @Remote gateway (breaks lanmode).
|
||||||
|
# Moving off it is a deliberate template edit, never a side effect of CI.
|
||||||
ARG DSH_VERSION=0.1.1-rc.2
|
ARG DSH_VERSION=0.1.1-rc.2
|
||||||
|
# pnpm is required on PATH by dsh's plugin manager (`dsh plugin --profile web
|
||||||
|
# add|update|remove`). CI passes the current release; the scheduled freshness
|
||||||
|
# check rebuilds a -rN revision tag when a newer pnpm or base image ships.
|
||||||
|
ARG PNPM_VERSION=12.3.4
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates git \
|
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" \
|
# pnpm is a fixed npm global rather than a corepack shim: corepack resolves
|
||||||
|
# versions at runtime and can attempt downloads/writes outside the state
|
||||||
|
# volume (its cache is root-owned), which breaks the non-root uid-1000 /
|
||||||
|
# read-only-rootfs contract. A pinned global install is deterministic,
|
||||||
|
# offline, and readable by uid 1000.
|
||||||
|
RUN npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
COPY web.cordis.patch.yml /opt/deepseek-harness/web.cordis.patch.yml
|
COPY web.cordis.patch.yml /opt/deepseek-harness/web.cordis.patch.yml
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
||||||
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh \
|
# /workspace: the agent's working directory (mount your project here);
|
||||||
# /workspace: the agent's working directory (mount your project here);
|
# /home/node/.dsh: dsh state (settings, credentials, sessions, plugins).
|
||||||
# /home/node/.dsh: dsh state (settings, credentials, sessions).
|
RUN mkdir -p /workspace /home/node/.dsh \
|
||||||
&& mkdir -p /workspace /home/node/.dsh \
|
|
||||||
&& chown node:node /workspace /home/node/.dsh
|
&& chown node:node /workspace /home/node/.dsh
|
||||||
|
|
||||||
ENV DSH_HOME=/home/node/.dsh
|
# Everything writable at runtime (dsh state, pnpm store/cache for plugin
|
||||||
|
# installs) lives inside the mounted state volume, never the rootfs.
|
||||||
|
ENV DSH_HOME=/home/node/.dsh \
|
||||||
|
PNPM_HOME=/home/node/.dsh/.pnpm \
|
||||||
|
XDG_CACHE_HOME=/home/node/.dsh/.cache \
|
||||||
|
XDG_STATE_HOME=/home/node/.dsh/.state
|
||||||
|
|
||||||
EXPOSE 3080
|
EXPOSE 3080
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
ARG DSH_VERSION
|
ARG DSH_VERSION
|
||||||
|
ARG PNPM_VERSION
|
||||||
|
# BASE_DIGEST: resolved digest of the FROM image at build time; the scheduled
|
||||||
|
# freshness check compares it (and pnpm-version) against current releases.
|
||||||
|
ARG BASE_DIGEST=""
|
||||||
LABEL org.opencontainers.image.title="deepseek-harness" \
|
LABEL org.opencontainers.image.title="deepseek-harness" \
|
||||||
org.opencontainers.image.description="DeepSeek Harness (dsh) web UI, built from the official npm release" \
|
org.opencontainers.image.description="DeepSeek Harness (dsh) web UI, built from the official npm release" \
|
||||||
org.opencontainers.image.version="${DSH_VERSION}" \
|
org.opencontainers.image.version="${DSH_VERSION}" \
|
||||||
org.opencontainers.image.source="https://gitea.milesward.dev/mward4/deepseek-harness" \
|
org.opencontainers.image.source="https://gitea.milesward.dev/mward4/deepseek-harness" \
|
||||||
org.opencontainers.image.url="https://github.com/deepseek-ai/deepseek-harness" \
|
org.opencontainers.image.url="https://github.com/deepseek-ai/deepseek-harness" \
|
||||||
org.opencontainers.image.licenses="MIT"
|
org.opencontainers.image.licenses="MIT" \
|
||||||
|
dev.milesward.dsh-version="${DSH_VERSION}" \
|
||||||
|
dev.milesward.pnpm-version="${PNPM_VERSION}" \
|
||||||
|
dev.milesward.base-digest="${BASE_DIGEST}"
|
||||||
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
ENTRYPOINT ["dsh"]
|
||||||
CMD []
|
# --patch must come immediately after `web` (launcher flag), --no-open so a
|
||||||
|
# headless server never launches a browser (on 0.1.2+ an in-container browser
|
||||||
|
# could consume the one-time launch token). Unraid Post Arguments REPLACE this
|
||||||
|
# CMD entirely — operators must repeat the patch flag and --no-open.
|
||||||
|
CMD ["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]
|
||||||
@@ -10,13 +10,13 @@ with a locally-built, auditable container image for homelab use (Unraid).
|
|||||||
| Branch | Contents |
|
| Branch | Contents |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| `master` | Exact mirror of upstream `master` (plus all `dsh-v*` tags). Never edited locally. |
|
| `master` | Exact mirror of upstream `master` (plus all `dsh-v*` tags). Never edited locally. |
|
||||||
| `main` | This branch: Dockerfile, entrypoint, and the sync/build workflow. |
|
| `main` | This branch: Dockerfile, patch file, and the sync/build workflow. |
|
||||||
|
|
||||||
## Image
|
## Image
|
||||||
|
|
||||||
```
|
```
|
||||||
gitea.milesward.dev/mward4/deepseek-harness:<version>
|
gitea.milesward.dev/mward4/deepseek-harness:<version> e.g. 0.1.1-rc.2
|
||||||
gitea.milesward.dev/mward4/deepseek-harness:latest
|
gitea.milesward.dev/mward4/deepseek-harness:<version>-r<N> toolchain refresh of the same version
|
||||||
```
|
```
|
||||||
|
|
||||||
Built from `node:22-bookworm-slim` + the **official** `@deepseek-ai/dsh` npm
|
Built from `node:22-bookworm-slim` + the **official** `@deepseek-ai/dsh` npm
|
||||||
@@ -24,6 +24,17 @@ release, pinned to the exact version. Upstream publishes no container image;
|
|||||||
this repo exists so the image provenance is:
|
this repo exists so the image provenance is:
|
||||||
upstream tag → upstream npm release → this Dockerfile → this registry.
|
upstream tag → upstream npm release → this Dockerfile → this registry.
|
||||||
|
|
||||||
|
**There is no `:latest` tag, deliberately.** This deployment once tracked
|
||||||
|
`latest`; a routine restart silently moved it from dsh 0.1.1-rc.2 to the
|
||||||
|
0.1.2 line, which introduced launch-token auth and replaced the ApiProxy
|
||||||
|
with the @Remote gateway (breaking the lanmode plugin). Every tag here is
|
||||||
|
immutable and every upgrade is an explicit template edit.
|
||||||
|
|
||||||
|
**Pinned default: `0.1.1-rc.2`** (`ARG DSH_VERSION` in the Dockerfile) — the
|
||||||
|
last version verified against this deployment: no launch-token auth, and
|
||||||
|
`@goodandready/dsh-lanmode` is compatible. Newer versions get their own tags
|
||||||
|
built by CI (see below) but nothing repoints until you edit the template.
|
||||||
|
|
||||||
No upstream code is modified. Upstream deliberately refuses `--host 0.0.0.0`
|
No upstream code is modified. Upstream deliberately refuses `--host 0.0.0.0`
|
||||||
on the CLI (the web UI can execute code, so exposing it to a network is a
|
on the CLI (the web UI can execute code, so exposing it to a network is a
|
||||||
deliberate opt-in); the supported opt-in is a `--patch` launcher layer, which
|
deliberate opt-in); the supported opt-in is a `--patch` launcher layer, which
|
||||||
@@ -39,11 +50,60 @@ Paths inside the container:
|
|||||||
| `/workspace` | The agent's working directory (container `WORKDIR`) |
|
| `/workspace` | The agent's working directory (container `WORKDIR`) |
|
||||||
| `/opt/deepseek-harness/web.cordis.patch.yml` | Baked web-server bind patch |
|
| `/opt/deepseek-harness/web.cordis.patch.yml` | Baked web-server bind patch |
|
||||||
|
|
||||||
Entrypoint behavior: no arguments → `dsh web --patch <baked patch> --no-open` (the `--patch` launcher flag must precede the web command’s own flags);
|
Runs as user `node` (UID/GID 1000). Everything writable at runtime — dsh
|
||||||
arguments starting with a flag (e.g. `--trusted-host tower.local`) are
|
state and pnpm's store/cache for plugin installs — lives inside the
|
||||||
appended to that default; a full command line (e.g.
|
`/home/node/.dsh` volume, never the rootfs.
|
||||||
`web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open`) runs as
|
|
||||||
given. Runs as user `node` (UID/GID 1000).
|
### Command contract
|
||||||
|
|
||||||
|
```
|
||||||
|
ENTRYPOINT ["dsh"]
|
||||||
|
CMD ["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--patch` must come **immediately after `web`** (it's a launcher flag; after
|
||||||
|
the web command's own flags it fails with "unknown option").
|
||||||
|
- `--no-open` is mandatory on a server: on 0.1.2+ an in-container browser
|
||||||
|
launch can consume the one-time launch token before you do.
|
||||||
|
- **Unraid "Post Arguments" REPLACE this CMD entirely.** If you set Post
|
||||||
|
Arguments you must repeat the whole command yourself:
|
||||||
|
|
||||||
|
```
|
||||||
|
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host <host> [--trusted-host <host2>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--trusted-host` is deployment-specific and is never baked into the image.
|
||||||
|
|
||||||
|
### Plugin management
|
||||||
|
|
||||||
|
pnpm ships in the image (dsh's plugin manager requires it), so plugin
|
||||||
|
operations work through dsh's own tooling against the state volume:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec <container> dsh plugin --profile web add <pkg>
|
||||||
|
docker exec <container> dsh plugin --profile web update <pkg>
|
||||||
|
docker exec <container> dsh plugin --profile web remove <pkg>
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins live in the runtime volume (`/home/node/.dsh/profiles/web`), not in
|
||||||
|
the image — image rebuilds never touch them.
|
||||||
|
|
||||||
|
### dsh 0.1.2+ launch-token auth (read before upgrading)
|
||||||
|
|
||||||
|
From 0.1.2, the Web UI requires a one-time launch token:
|
||||||
|
|
||||||
|
- The tokened URL is printed in the startup log: `docker logs <container>`.
|
||||||
|
- The token **rotates on every restart** of the container.
|
||||||
|
- Opening the tokened URL exchanges it for a ~30-day cookie **bound to the
|
||||||
|
exact origin you opened it on** — do the exchange on the hostname you
|
||||||
|
actually intend to use (e.g. your tunnel domain, not the LAN IP).
|
||||||
|
- Bare requests without token/cookie get "dsh web authentication required".
|
||||||
|
|
||||||
|
**Known incompatibility:** `@goodandready/dsh-lanmode` does not work with
|
||||||
|
dsh 0.1.2+ (the @Remote gateway replaced the ApiProxy it monkey-patches;
|
||||||
|
symptom: "web boot: 26 entries did not activate ... waiting for services:
|
||||||
|
remote.*"). Remove or update it *before* moving to 0.1.2:
|
||||||
|
`docker exec <container> dsh plugin --profile web remove @goodandready/dsh-lanmode`.
|
||||||
|
|
||||||
## Unraid template
|
## Unraid template
|
||||||
|
|
||||||
@@ -61,7 +121,7 @@ Otherwise dsh fails with `EACCES` on `/home/node/.dsh`.
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<Container version="2">
|
<Container version="2">
|
||||||
<Name>DeepSeek-Harness</Name>
|
<Name>DeepSeek-Harness</Name>
|
||||||
<Repository>gitea.milesward.dev/mward4/deepseek-harness:latest</Repository>
|
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2</Repository>
|
||||||
<Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
|
<Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
|
||||||
<Network>bridge</Network>
|
<Network>bridge</Network>
|
||||||
<Shell>sh</Shell>
|
<Shell>sh</Shell>
|
||||||
@@ -76,12 +136,15 @@ FIRST RUN (required): the container runs as UID 1000. Before starting, from the
|
|||||||
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
|
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
|
||||||
Otherwise dsh fails with EACCES on /home/node/.dsh.
|
Otherwise dsh fails with EACCES on /home/node/.dsh.
|
||||||
|
|
||||||
SECURITY - READ BEFORE EXPOSING: the dsh Web UI has no authentication or TLS, and its web API can execute code on this container. Anything that can reach the port can run commands. Keep it on a trusted LAN, or put an authenticated reverse proxy (e.g. Cloudflare Access, Authelia) in front. Never port-forward it directly to the internet.
|
SECURITY - READ BEFORE EXPOSING: the dsh Web UI has no authentication or TLS (before 0.1.2), and its web API can execute code on this container. Anything that can reach the port can run commands. Keep it on a trusted LAN, or put an authenticated reverse proxy (e.g. Cloudflare Access, Authelia) in front. Never port-forward it directly to the internet.
|
||||||
|
|
||||||
REMOTE ACCESS: dsh rejects non-loopback requests (HTTP 403 on /api) unless the address you browse from is trusted. To use the Web UI from another machine, edit Post Arguments and append: --trusted-host YOUR-UNRAID-IP-OR-HOSTNAME
|
POST ARGUMENTS REPLACE THE IMAGE COMMAND ENTIRELY. Always include the patch flag and --no-open, then your trusted hosts:
|
||||||
The Settings/credentials pages are additionally restricted to loopback pages by upstream design; edit them via SSH tunnel (ssh -L 3080:127.0.0.1:3080 root@unraid-ip, then browse http://127.0.0.1:3080). The @goodandready/dsh-lanmode community plugin can lift that restriction, but it is third-party code from an unaffiliated author that disables an upstream security fence - review it yourself before trusting it with your credentials.
|
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host YOUR-HOST [--trusted-host YOUR-HOST2]
|
||||||
|
Without a matching --trusted-host, non-loopback requests get HTTP 403 on /api - that is upstream's DNS-rebinding/CSRF fence working as intended.
|
||||||
|
|
||||||
VERSIONS: this registry publishes immutable version tags AND a :latest tag that CI moves to each new official npm release. dsh is a developer preview with breaking changes between releases - pin a version tag if you want to update deliberately.
|
The Settings/credentials pages are additionally restricted to loopback pages by upstream design; edit them via SSH tunnel (ssh -L 3080:127.0.0.1:3080 root@unraid-ip, then browse http://127.0.0.1:3080). The @goodandready/dsh-lanmode community plugin can lift that restriction on dsh 0.1.1 (it is NOT compatible with 0.1.2+, and it is third-party code that disables an upstream security fence - review it before trusting it with your credentials).
|
||||||
|
|
||||||
|
VERSIONS: this registry publishes immutable version tags only - NO :latest. dsh is a developer preview with breaking changes between releases (0.1.2 adds mandatory launch-token auth: token in docker logs, rotates every restart). Upgrade by editing this tag deliberately. Tags like 0.1.1-rc.2-rN are the same dsh version rebuilt with updated pnpm/base-image security patches.
|
||||||
|
|
||||||
Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
|
Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
|
||||||
</Overview>
|
</Overview>
|
||||||
@@ -98,26 +161,28 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
|
|||||||
</Container>
|
</Container>
|
||||||
```
|
```
|
||||||
|
|
||||||
To reach the UI from your LAN, append your Unraid host to Post Arguments,
|
|
||||||
e.g.:
|
|
||||||
|
|
||||||
```
|
|
||||||
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host tower.local --trusted-host 192.168.1.50
|
|
||||||
```
|
|
||||||
|
|
||||||
(port-less entries match any port). Without it, anything but a loopback Host
|
|
||||||
header gets a 403 from `/api` — that's upstream's DNS-rebinding/CSRF fence,
|
|
||||||
working as intended.
|
|
||||||
|
|
||||||
## Keeping current
|
## Keeping current
|
||||||
|
|
||||||
[`.gitea/workflows/sync-upstream.yml`](.gitea/workflows/sync-upstream.yml)
|
[`.gitea/workflows/sync-upstream.yml`](.gitea/workflows/sync-upstream.yml)
|
||||||
runs every 6 hours on the Gitea Actions runners:
|
runs every 6 hours on the Gitea Actions runners:
|
||||||
|
|
||||||
1. Force-syncs upstream `master` and `dsh-v*` tags into this repo.
|
1. Force-syncs upstream `master` and `dsh-v*` tags into this repo.
|
||||||
2. Resolves the official npm `latest` version of `@deepseek-ai/dsh`.
|
2. Builds a **new immutable version tag** when a new `@deepseek-ai/dsh`
|
||||||
3. If the registry lacks an image for that version, builds and pushes it,
|
appears on npm and the registry lacks it. Existing tags are never
|
||||||
moving `:latest` along with it.
|
re-pushed, and `latest` is never published or moved — a new upstream
|
||||||
|
release cannot change what a running deployment executes.
|
||||||
|
3. **Toolchain freshness check** for the pinned default version: if a newer
|
||||||
|
pnpm release or an updated `node:22-bookworm-slim` base image (which is
|
||||||
|
where `npm` itself comes from) has shipped since the newest existing
|
||||||
|
build, CI builds a `<version>-r<N>` revision tag — same dsh, patched
|
||||||
|
toolchain. Adopting it is still a deliberate template edit.
|
||||||
|
|
||||||
|
Every build must pass a smoke test before it is pushed: `dsh --version` and
|
||||||
|
`pnpm --version` match the intended versions, and the web server boots with
|
||||||
|
the baked patch and answers HTTP on the container's non-loopback interface
|
||||||
|
(200 on pre-token versions; 303/401 on 0.1.2+, which proves the server is up
|
||||||
|
behind its launch-token gate). A dsh release the Cordis patch no longer
|
||||||
|
applies to fails here, loudly, instead of at runtime.
|
||||||
|
|
||||||
## Validating an image
|
## Validating an image
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Container entrypoint for dsh.
|
|
||||||
#
|
|
||||||
# The image ships /opt/deepseek-harness/web.cordis.patch.yml, which binds the
|
|
||||||
# web server to 0.0.0.0:3080 through upstream's supported `--patch` launcher
|
|
||||||
# layer (the CLI's own --host flag deliberately refuses 0.0.0.0). Exposure is
|
|
||||||
# still controlled entirely by which ports you publish on the Docker host, and
|
|
||||||
# the /api browser-trust fence refuses Host headers you haven't allowed with
|
|
||||||
# --trusted-host.
|
|
||||||
#
|
|
||||||
# Argument handling:
|
|
||||||
# (no args) -> dsh web --no-open --patch <baked patch>
|
|
||||||
# --trusted-host x ... -> flags are appended to that default invocation
|
|
||||||
# web --patch ... / any -> a full dsh command line is executed as given
|
|
||||||
# subcommand (Unraid templates pass one via Post Arguments)
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
DSH_HOME="${DSH_HOME:-${HOME}/.dsh}"
|
|
||||||
export DSH_HOME
|
|
||||||
|
|
||||||
PATCH=/opt/deepseek-harness/web.cordis.patch.yml
|
|
||||||
|
|
||||||
if [ "$#" -eq 0 ]; then
|
|
||||||
set -- web --patch "$PATCH" --no-open
|
|
||||||
else
|
|
||||||
case "$1" in
|
|
||||||
-*) set -- web --patch "$PATCH" --no-open "$@" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec dsh "$@"
|
|
||||||
Reference in new issue
Block a user