- Replace user:token cookies with opaque server-side sessions, revocable on logout, delete, recreate and expiry; bind every credential to the path user and add cross-user regression tests. - Rate-limit /users and /login, add optional signup invite codes and form same-origin checks, and unify login failures to 401. - Require explicit ALLOW_INSECURE_COOKIES for plain HTTP, use __Host-dsh_gw_session when secure, and fix secure cookie deletion. - Strip client-supplied forwarding/SSO headers, add an optional upstream allowlist, per-user in-flight and WebSocket message caps, credential-free handshake errors, and sweep-time state pruning. - Digest-pin base images, hash-pin gateway dependencies, verify DSH_COMMIT in the build, and run the gateway image as non-root with a pinned Python base. - Update manifests, production checklist, architecture/security docs and add an SSO preflight script. BREAKING CHANGE: existing dsh_gw_session=<user>:<token> cookies are invalid; users must sign in again. Unknown users on POST /login now return 401 instead of 404, and the gateway refuses COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1. Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
13 KiB
The main DSH Web image
This repository's primary artifact is a container image that packages
DeepSeek Harness Web
(dsh web) for Kubernetes and plain Docker. The Dockerfile at the
repository root builds it; every version pin it uses lives in
versions.env.
The image is image-first: it is the product, and the examples under
examples/ are self-contained implementations that consume it.
What the build does
The Dockerfile is a two-stage build. Both stages use the node images pinned by
digest in versions.env: a mutable tag alone is not trusted, and
./scripts/check-versions.sh rejects an image reference without a @sha256: digest.
- Build stage (
NODE_BUILD_IMAGE) clones DSH at the pinned git ref, verifies that the clone'sHEADis exactlyDSH_COMMIT(the build fails otherwise, so a moved upstream tag cannot silently change what the image packages), installs dependencies with pnpm, runs the production build, packages the@deepseek-ai/dshCLI withpnpm pack, and installs its production dependencies with npm. - Runtime stage (
NODE_RUNTIME_IMAGE) copies/opt/dshto/app, adds the CLI tools DSH agents commonly use (git,ripgrep,jq,yq,curl,wget,gh,tini,uv), installsbubblewrapas DSH's preferred command-sandbox runner, and startsdsh webthrough a small entrypoint.
Build it
make image # canonical: builds the DSH Web image, no push
make image reads versions.env and passes the pins as build args.
Pull requests that touch Dockerfile, .dockerignore or versions.env get the
same build for linux/amd64 from the ci-image.yaml
workflow — no registry login, no push — followed by a container smoke test.
To reproduce the equivalent by hand:
docker build \
--build-arg DSH_VERSION=dsh-v0.2.1-rc.5 \
--build-arg DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1 \
--build-arg RELEASE_REVISION= \
--build-arg PNPM_VERSION=11.28.5 \
--build-arg VCS_REF="$(git rev-parse --short HEAD)" \
-t ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5 .
Build args
| Arg | Default / source | Purpose |
|---|---|---|
DSH_REPO |
https://github.com/deepseek-ai/deepseek-harness.git |
Upstream DSH repository to clone. |
DSH_VERSION |
DSH_VERSION in versions.env |
Upstream DSH git tag/ref to package. The Dockerfile records it as io.sredevopsorg.dsh.version and (for local builds) org.opencontainers.image.version; published builds set the version label to the full release identity. |
DSH_COMMIT |
DSH_COMMIT in versions.env |
Full commit SHA DSH_VERSION must resolve to upstream. The build stage fails unless git rev-parse HEAD in the clone equals this value, which pins the source independently of a movable tag. |
RELEASE_REVISION |
RELEASE_REVISION in versions.env |
Repository revision suffix (rN), recorded in io.sredevopsorg.release.revision. The release identity is DSH_VERSION plus -RELEASE_REVISION when set. See versioning. |
PNPM_VERSION |
PNPM_VERSION in versions.env |
pnpm version activated with corepack in both stages. |
VCS_REF |
git rev-parse --short HEAD (via make image) |
Source revision recorded for traceability. |
NODE_BUILD_IMAGE |
docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19 |
Build-stage base image, pinned to the multi-arch index digest. |
NODE_RUNTIME_IMAGE |
docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446 |
Runtime-stage base image, pinned to the multi-arch index digest. |
Base images are pinned by digest because tags are mutable: node:24-trixie can be
re-pointed at a new build at any time. Bumping the tag and its digest is a
deliberate, reviewable change in versions.env; environments that
mirror the image should also mirror the node digest they build from.
Never hard-code a version outside versions.env:
./scripts/check-versions.sh (and make check) fails when a pin drifts.
Runtime contract
Entrypoint
The entrypoint is tini -- /usr/local/bin/dsh-entrypoint:
tiniruns as PID 1 and reaps children/forwards signals.- The shell entrypoint applies defaults, derives a bind address when
DSH_HOSTis empty (the pod/container's first non-loopback IPv4), builds thedsh webcommand line, andexecs it so signals reach the Node process. localhostand127.0.0.1are always added to DSH's trusted-host list; eachDSH_TRUSTED_HOSTSentry is added as an extra--trusted-host.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
DSH_PORT |
3080 |
Port DSH Web listens on inside the container. |
DSH_HOST |
auto-detected non-loopback IPv4 | Bind address. Leave empty in Kubernetes so the pod IP is used automatically. |
DSH_PUBLIC_URL |
http://localhost:${DSH_PORT} |
Public base URL used to build links and the launch-token URL printed in the logs. Set it when the container is published on a different host port or behind a proxy. |
DSH_TRUSTED_HOSTS |
empty | Extra Host/Origin allow-list entries, comma- or space-separated. localhost/127.0.0.1 are always trusted. Required when a proxy forwards the browser's Host. |
DEEPSEEK_API_KEY |
empty | Provider credential read by DSH. Inject it with -e/envFrom; never bake it into the image. Only model calls fail when it is missing. |
DSH_HOME |
/home/node/.dsh |
DSH state directory (conversations, sessions, config, credentials). Mount a volume here to persist it. |
HOME |
/home/node |
OS home: where ~ resolves and where tools keep caches and config. DSH's directory picker has no start-path setting, so it lists $HOME when opened. The shipped sandbox templates override it to /workspace so the picker opens on the persisted workspace; the trade-offs are below. |
DSH_PERMISSION_MODE |
workspace-write |
Deployment default for DSH's file policy: read-only, workspace-write, or danger-full-access (which also turns approval prompts off). The confined modes need bubblewrap or Landlock inside the container; where neither is usable every bash command fails closed with SANDBOX_UNAVAILABLE. See the note below. |
The image also sets NODE_ENV=production, HOME=/home/node, and a PATH that
includes /app/node_modules/.bin.
HOME decides where DSH's folder picker starts. DSH has no start-path setting:
the picker's browse backend lists $HOME when the dialog opens and labels that
listing Home. With the image default (/home/node) that listing is outside a
mounted volume; the example sandbox templates
(minimal,
agents as a service)
mount the same PVC's workspace subPath at /workspace and set HOME=/workspace
so the picker opens on the persisted agent workspace. ~
and every tool that keeps caches or config under $HOME (npm/pnpm, uv,
git, gh, DSH's global ~/.agents instructions and skills) follow, so those
dotfiles then live on the workspace volume too. DSH_HOME is separate and stays
/home/node/.dsh, so sessions, config and credentials stay out of the
workspace. An existing sandbox stamps this environment at pod creation, so the
override only applies to sandboxes created after it was added.
DSH confines bash commands itself (a second layer inside whatever container
sandbox you already run). Its Linux runners are bubblewrap and then Landlock,
and it fails closed rather than running unconfined. The image ships
bubblewrap, but Debian's binary is unsetuid: it needs unprivileged user
namespaces plus the namespace/mount syscalls. A hardened pod — non-root,
capabilities.drop: ["ALL"], seccompProfile: RuntimeDefault — usually cannot
create one, and it may expose no Landlock runner either, in which case every
command fails with SANDBOX_UNAVAILABLE. Set
DSH_PERMISSION_MODE=danger-full-access only when the surrounding pod or VM is
the isolation boundary you rely on; keep the confined modes when the host
provides a working runner (check grep -o landlock /sys/kernel/security/lsm and
bwrap --ro-bind / / true inside the container first).
Provider credentials
DSH resolves provider API keys from the environment when no stored credential exists. The packaged provider catalog maps each provider to its standard environment name. Commonly used names:
DEEPSEEK_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY,
OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, XAI_API_KEY,
MISTRAL_API_KEY, TOGETHER_API_KEY, CEREBRAS_API_KEY,
AI_GATEWAY_API_KEY, HF_TOKEN.
Use GEMINI_API_KEY for Google Gemini (not GOOGLE_API_KEY), HF_TOKEN for
Hugging Face, and AI_GATEWAY_API_KEY for the Vercel AI Gateway. Inject keys
with -e/--env-file in Docker or a Secret + envFrom in Kubernetes; never
bake them into the image.
A key saved through DSH's credential store ($DSH_HOME/.credentials.yaml) wins
over the environment. The
agents-as-a-service example
documents the optional dsh-provider-keys-extra Secret and the pod-refresh
procedure.
Ports, volumes and signals
| Item | Value | Notes |
|---|---|---|
| Exposed port | 3080/tcp |
EXPOSE 3080; one port serves the UI, assets, API, SSE and WebSocket traffic. |
| State volume | /home/node/.dsh |
Declared with VOLUME; use a named volume (Docker) or PVC (Kubernetes). |
| Working directory | /workspace |
The agent workspace. Mount a volume here if agents should keep files; the shipped example templates mount the same PVC's workspace subPath here and point HOME at it, so DSH's folder picker opens on it. |
| Stop signal | SIGTERM |
tini forwards it to dsh web; allow a graceful shutdown period. |
| User | node, UID/GID 1000 |
The process is non-root. A PVC mounted at DSH_HOME must be writable by UID 1000. |
Health probes
/ is not a health endpoint: DSH returns 401 until a browser completes the
launch-token handshake. Probe /favicon.svg instead — it is unauthenticated and
served by the web layer, so it proves the HTTP server is up without triggering auth.
startupProbe:
httpGet: {path: /favicon.svg, port: 3080}
periodSeconds: 5
failureThreshold: 24
readinessProbe:
httpGet: {path: /favicon.svg, port: 3080}
periodSeconds: 5
livenessProbe:
httpGet: {path: /favicon.svg, port: 3080}
periodSeconds: 15
The shipped templates use the same path and startup budget; see
examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml.
A startupProbe is important because plugin activation (or the first start of a
large sandbox) can exceed a default liveness budget.
Launch token
DSH prints its one-time launch URL to the logs:
dsh web: http://<host>:${DSH_PORT}/?token=...
Open that URL once to obtain DSH's auth cookie. After that the UI works at the bare
URL; without the cookie / answers 401 by design. The
agents-as-a-service gateway performs
this handshake server-side so browsers never see the token.
Published image names and tags
Registry layout: the main image is the repository itself, and every example component is a nested package below it:
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox # main DSH Web image
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway # example component
The main image is published multi-arch (linux/amd64, linux/arm64) to
GitHub Container Registry:
| Tag | Meaning |
|---|---|
dsh-v0.2.1-rc.5 |
Current release identity. Immutable: once published it never moves. |
latest |
Most recent release; points at the same digest as the current identity. |
main |
Development channel rebuilt when image-definition files change on main; not a release, not for production. |
Release identities, channels and rollback rules are defined in versioning. Tags are immutable, so production deployments should pin a digest:
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5
# ... pin the digest in your deployment manifests:
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
Verifying an image
docker inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5 \
--format '{{json .Config.Labels}}'
# org.opencontainers.image.title / .version / .source
# io.sredevopsorg.dsh.version / io.sredevopsorg.release.revision
Release builds also publish SBOM and provenance attestations from the release workflow (see SECURITY.md and gitflow).