1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 09:23:25 +00:00

22 Commits

Author SHA1 Message Date
Nicolás Georger
f4c0b52ee9 Merge branch 'chore/changelog-dsh-v0.2.1-rc.5-r6' into fix/security-gateway-sessions
Signed-off-by: Nicolás Georger <info@sredevops.org>
2026-10-31 07:38:56 -03:00
github-actions[bot]
96837a43b6 chore(changelog): refresh for dsh-v0.2.1-rc.5-r6 2026-10-31 09:31:08 +00:00
Nicolás Georger
8e9bd478b4 chore: improve package installation and add Python depend… (#42)
* chore(dockerfile): improve package installation and add Python dependencies

* chore(dockerfile): update package installation commands for consistency

* chore: update release version to dsh-v0.2.1-rc.5-r6 across documentation and configuration files
2026-10-31 06:25:39 -03:00
SREDevOps.org Agent
1a929e3e24 fix(gateway)!: replace token cookies with revocable sessions and harden abuse controls (#40)
- 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>
2026-10-31 00:57:35 -03:00
SREDevOpsAgents
2eb5c82ace Merge remote-tracking branch 'origin/main' into fix/security-gateway-sessions
# Conflicts:
#	docs/versioning.md
#	versions.env
2026-10-31 00:55:39 -03:00
SREDevOpsAgents
ae8aba8241 fix(gateway)!: replace token cookies with revocable sessions and harden abuse controls
- 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.
2026-10-31 00:54:05 -03:00
SREDevOps.org Agent
2f959e6afb fix(ci): harden workflow token scope and download integrity (#39)
Security audit of .github/workflows found a script-injection vector in the
release workflow, over-broad token grants on non-publishing jobs, and
downloaded tooling/manifests executed without integrity checks.

- release: pass the tag/dispatch input through env instead of interpolating
  it into the validation script; default workflow permissions to contents:read
- build-main/build-gateway: grant write scopes only to the build job
- _build-image/codeql: stop persisting checkout credentials
- ci/release: verify actionlint, hadolint and git-cliff against pinned SHA-256s
- ci-manifests/e2e: verify upstream CRD manifests against versions.env pins
- e2e: redact token material before uploading cluster diagnostics
- docs: add workflow-security.md with controls, audit record and residual
  repository-setting recommendations

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 15:16:51 -03:00
SREDevOps.org Agent
4c073eef71 chore(deps): bump agent-sandbox to v1.0.9 (#38)
Update AGENT_SANDBOX_VERSION from v1.0.8 to v1.0.9, the pin the examples
and the CI manifest dry-run consume. Refresh the compatibility matrix in
docs/versioning.md and clarify the failedPodPolicy sentence in the
hardening doc, which described v1.0.8 as the pin.

Reviewed the v1.0.9 release for breaking changes: the new
volumeClaimTemplates admission rules accept the named "dsh-home" templates
in both examples, and the router NetworkPolicy / Go label-constant changes
do not apply to this repository.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 13:11:39 -03:00
SREDevOps.org Agent
24f23e09ae docs: align readmes, docs and agent guidance with current behavior (#37)
Remove obsolete layout/migration prose and legacy gateway-alias references, reconcile the required-check policy, fix version-label/RBAC/workspace/channel drift, and de-duplicate the example and gateway docs.

Rename the minimal manifest to 10-sandboxtemplate.yaml and mount the dsh-home PVC's workspace subPath at /workspace so the documented persistence is real. Enforce numbered top-level manifests in check-examples.sh.

Verified with make check, yamllint, shellcheck, a Markdown link/anchor scan, and git diff --check.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 12:31:50 -03:00
SREDevOps.org Agent
82ee0d0cdb feat(example): start DSH's folder picker on the persisted workspace (#36)
The directory picker has no start-path setting: it lists $HOME whenever it
opens. With the image default HOME=/home/node the folder explorer started on
a throwaway directory, so point HOME at the PVC-backed /workspace in both
sandbox templates. `~` and $HOME-relative tool caches/config follow; DSH_HOME
stays /home/node/.dsh, so sessions, config and credentials are unaffected.

Document the trade-off in the image contract and both examples, add
troubleshooting for sandboxes whose pod env was stamped before the change,
and assert HOME/~ in the e2e walkthrough.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 11:19:03 -03:00
SREDevOps.org Agent
a09d4ac340 fix: ship bubblewrap for DSH's command sandbox, keep agents usable without it (#35)
* fix(example): keep agents usable when DSH's inner sandbox has no backend

DSH confines bash commands itself (bubblewrap, else Landlock) and fails closed
with SANDBOX_UNAVAILABLE when no runner is usable. In this hardened pod -
non-root, capabilities dropped, seccomp RuntimeDefault - bubblewrap cannot
start (it needs CAP_SYS_ADMIN) and the pod exposes no Landlock runner, so the
shipped workspace-write default made every command fail:

  sandbox mode "workspace-write" is requested but no sandbox backend is usable

The DSH base bundle at the pinned ref documents DSH_PERMISSION_MODE as the
explicit deployment override, so set it to danger-full-access in both sandbox
templates. The per-user pod is the isolation boundary in this example (its own
pod, PVC and Service; non-root; all capabilities dropped), so a working agent
is worth more than an inner file policy that cannot be enforced. Note that
danger-full-access also turns approval prompts off.

Trade-off documented rather than hidden: an agent command can now read its own
DSH_HOME (including .credentials.yaml) and write anywhere in the container.
Hosts where a runner works should keep workspace-write or read-only.

- main-image docs: DSH_PERMISSION_MODE row plus a note on the runners and the
  fail-closed behavior
- example README: limits bullet and a troubleshooting row with the in-pod
  backend check and how to restore a confined mode
- architecture: tenant-isolation bullet extended
- skill runbook/troubleshooting: operation guidance; never grant CAP_SYS_ADMIN
  or seccompProfile: Unconfined to "fix" this without an explicit decision

Verified: make check (14 manifests), make lint, YAML assertions on both
templates, and the override expression at the pinned dsh-v0.2.1-rc.5 base
bundle (mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write').

* fix(image): install bubblewrap for DSH's command sandbox

DSH's Linux sandbox runner chain is bubblewrap, then Landlock, and it fails
closed with SANDBOX_UNAVAILABLE when neither works. The image shipped neither,
so a sandboxed agent had no way to execute confined commands.

Add bubblewrap to the runtime stage. Debian ships it unsetuid (0.12.0), so it
still needs unprivileged user namespaces plus the namespace/mount syscalls: in a
hardened pod (all capabilities dropped, RuntimeDefault seccomp) it usually
cannot create one, and DSH then falls back to Landlock or, failing that, to the
DSH_PERMISSION_MODE=danger-full-access override the templates already set.
Where the host allows an unprivileged user namespace, the first-choice runner
now exists, so the confined modes can be restored.

Docs corrected to match: bubblewrap is no longer "absent"; the blocker is the
unsetuid binary's namespace requirement, not a missing CAP_SYS_ADMIN grant.

Verified: make check, make lint; the package exists in the pinned base
(node:24-trixie-slim, bubblewrap 0.12.0-1~deb13u1); a throwaway container with
the pod's flags (--user 1000 --cap-drop ALL --security-opt no-new-privileges)
still cannot create a namespace, which is why the override stays. No local image
build was run.

* ci(image): build and smoke-test the main image on PRs that touch it

The Dockerfile and the pins in versions.env could only be validated by a merge
to main or by running `make image` by hand; neither gives a PR-time signal.

Add ci-image.yaml: on pull requests that change Dockerfile, .dockerignore or
versions.env (and on manual dispatch) it builds linux/amd64 natively with the
pins sourced from versions.env - no registry login, no push - loads the image
locally and smoke-tests it:

- the packaged CLI runs and reports the pinned version
- the container is the non-root node user (1000) and owns /workspace + DSH_HOME
- the bubblewrap command-sandbox runner is present
- the OCI version label matches versions.env
- a container serves /favicon.svg (the same unauthenticated probe the shipped
  manifests use), with logs filtered so the launch token is never printed

Path-filtered like the other on-demand checks, so it is not a required status
check; the published multi-arch build stays in build-main.yaml and release.yaml.

Docs updated in the same change: AGENTS.md repository map and testing table,
docs/gitflow.md checks section, docs/main-image.md build section.

Verified: actionlint 1.7.12 and yamllint 1.38.0 (CI versions) clean over the
whole repository, plus make check and make lint.

---------

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 08:36:21 -03:00
SREDevOps.org Agent
367294b230 feat(example): persist the agent workspace on the PVC via subPath (#34)
/workspace - the DSH image's WORKDIR, where agents keep files - was plain
container storage, so every suspend/resume or pod replacement silently threw
agent work away while DSH state survived on the PVC.

Mount the same per-sandbox `dsh-home` PVC a second time at /workspace with
`subPath: workspace`. One PVC, no extra provisioning, and the subPath keeps
DSH state (sessions, config and .credentials.yaml at the volume root) out of
the agent-visible tree; mounting the volume root at /workspace would have
exposed the credential file.

The kubelet creates the missing subPath directory itself and chmods it to the
volume's fsGroup permissions, and the volume root's setgid bit makes the new
directory inherit gid 1000, so UID 1000 can write with no init container and
no root. The rare interrupted-kubelet case that leaves 0755 is documented with
a recovery procedure (kubernetes#142464) rather than papered over.

- e2e-kind: write a /workspace marker alongside the DSH_HOME marker, assert
  the mount is the dsh-home subPath, and verify both survive suspend/resume.
- Docs: README (overview, steps 4-6, troubleshooting row), architecture
  (PVC diagram, persistence, component table), main-image working-directory
  row, platform runbook PVC row.

Verified: make check (14 manifests), make lint, plus YAML assertions on the
two mounts and the unchanged volumeClaimTemplates.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 08:29:39 -03:00
SREDevOps.org Agent
cbcc56f97e fix(gateway): answer client-aborted requests with 499, not a traceback (#32)
* fix(gateway): answer client-aborted requests with 499, not a traceback

A browser reload, back navigation or proxy timeout can abort a request while
its body is still arriving. Starlette then raises ClientDisconnect from
request.stream(); nothing caught it, so ServerErrorMiddleware logged a full
ERROR traceback per aborted request and buried real faults. The request never
reaches DSH (the body is buffered before the upstream send) and the peer can
no longer be answered, so this is not an error.

- Register a ClientDisconnect exception handler that answers 499
  (CLIENT_CLOSED_REQUEST). One handler covers the proxy and the body-reading
  control routes (signup, login). A request already streaming keeps its
  upstream status because the response has started.
- Make _release await-free, and reach it even when the upstream close is
  cancelled. CancelledError is a BaseException, so suppress(Exception) around
  upstream.aclose() skipped the release and stranded the in-flight slot,
  leaving that user's sandbox looking busy forever (the idle sweeper then
  never suspends it).
- Add raw-ASGI regression tests: mid-body abort on the proxy and on signup,
  mid-stream abort, a cancelled release, and a cancelled upstream close.
- Document the 499 semantics in the gateway README failure table and the
  architecture failure modes.

Verified: make check, make test (114 passed), make lint. Against the pinned
uvicorn 0.54 the abort reproduces the original traceback before the fix and
logs no traceback after it.

* chore(deps): bump gateway starlette to 1.10.0 and ruff to 0.17.2

Absorbs Dependabot PR #27 against current main: that branch predates the
dsh-v0.2.1-rc.5 changelog/release commits, so merging it as-is would have
reverted them.

starlette 1.10.0 reworks URL-encoded form parsing (fields are built in the
parser callbacks, and the last field is kept when the stream ends without an
empty chunk) and clears tracebacks of handled exceptions. The gateway reads
JSON and form bodies, so the existing suite exercises signup/login forms and
the 499 client-abort path against the new release. ruff 0.17.2 only adds
preview and fix-safety changes; the configured rule set reports nothing new.

Verified: make check, make test (114 passed), make lint, make gateway (image
builds on python:3.12-slim and imports starlette 1.10.0), plus a direct ASGI
abort probe under 1.10.0 (499, in-flight slot released, nothing forwarded).

---------

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 07:14:49 -03:00
dependabot[bot]
8c34f43789 chore: bump docker/login-action (#28)
Bumps the actions group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](dbcb813823...81c6f93d4b)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-30 06:15:25 -03:00
Nicolás Georger
97c563296a Add continue-on-error to CHANGELOG PR step
Signed-off-by: Nicolás Georger <info@sredevops.org>
2026-10-30 06:14:27 -03:00
SREDevOps.org Agent
dcb5b436f6 ci(workflows): streamline CI and remove redundant builds (#31)
Streamline the CI and release workflows:

- ci.yaml is now PR-only (no push-to-main re-run) and keeps only lint and
  dependency-review; the gateway-image local build/smoke job is removed.
- Split the path-sensitive jobs into ci-python.yaml and ci-manifests.yaml,
  each using native on.pull_request.paths filtering so unrelated PRs skip them.
- Drop the duplicate check-manifests.py run from the lint job.
- release.yaml: skip the tests job steps for tags already on main (where PR CI
  already ran), keeping them only for release-branch tags.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 05:48:18 -03:00
SREDevOps.org Agent
b57916d854 chore(changelog): refresh for dsh-v0.2.1-rc.5 (#30)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-30 05:07:57 -03:00
SREDevOps.org Agent
bed8e5675a chore(release): dsh-v0.2.1-rc.5 (#29)
Bump DSH_VERSION to dsh-v0.2.1-rc.5 and clear RELEASE_REVISION, making the release identity dsh-v0.2.1-rc.5. Update every derived manifest, image tag, and doc, and remove prose that described the previous identity's legacy-tag history.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-30 04:54:37 -03:00
SREDevOps.org Agent
14c3fb840e feat(example): add optional failed-Pod recovery (#26)
agent-sandbox v1.0.8 adds spec.failedPodPolicy to Sandbox and
SandboxTemplate. The default, Retain, leaves a Failed Pod in place and
surfaces Finished=True; Recreate deletes it and starts a replacement with
the same Sandbox identity and PVCs.

Ship it as an opt-in toggle next to the other commented fields, with a
new section 5 in the optional-hardening doc: what it changes, how to
enable it for new, warm-pool and already-claimed Sandboxes (the policy is
stamped at creation), a failure drill, caveats and rollback. The base
walkthrough stays on Retain because a deterministic failure would
otherwise recreate forever, and because the templates use
restartPolicy OnFailure, so the policy only matters for terminal cases
such as node eviction.

Cross-references updated in the example README, the optional-manifests
index, the architecture failure-mode table and the platform skill's
troubleshooting table.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-26 14:29:01 -03:00
SREDevOps.org Agent
96d3030a49 chore(deps): bump agent-sandbox to v1.0.8 (#25)
Update AGENT_SANDBOX_VERSION to v1.0.8 and the docs that quote the
control-plane pin (example prerequisites, architecture, versioning table,
minimal-single-tenant template comment, platform skill).

The v1.0.8 release adds only one CRD field, spec.failedPodPolicy
(Sandbox and SandboxTemplate, enum Retain|Recreate, default Retain), so
the existing manifests keep their behavior: Retain is the default and
neither the API version (v1beta1) nor any field we set changed. All five
custom resources still validate against the v1.0.8 CRD schemas.

Behavior changes in the controller itself (bounded MCP output, router
kustomization, creation-latency metric timing) do not affect the
platform because it uses neither the sandbox-router nor the MCP server.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-26 14:17:28 -03:00
SREDevOps.org Agent
2b43d5dfd5 fix(ci): make release image digests unique and allow changelog PRs (#24)
The reusable image build ran once for the main image and once for the gateway in the same release run, but both used digests-<platform> artifact names, so the main merge downloaded a gateway digest and failed. Include a short hash of the full image name in the artifact name and download pattern.

The publish-release job also lacked pull-requests: write, so the generated CHANGELOG PR failed with 'Resource not accessible by integration'. Grant the job contents and pull-requests write.

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
2026-10-26 10:34:21 -03:00
SREDevOps.org Agent
97abf75dfe chore(changelog): refresh for dsh-v0.2.1-rc.3-r4 (#23)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 10:27:02 -03:00
58 changed files with 6859 additions and 909 deletions

View File

@@ -104,8 +104,9 @@ Verified from the upstream server:
`shutdown_after_seconds` defaults to 300 s (`null` disables it). `shutdown_after_seconds` defaults to 300 s (`null` disables it).
- **Output:** servers before v1.0.8 return unbounded `execute_command` output. - **Output:** servers before v1.0.8 return unbounded `execute_command` output.
v1.0.8 adds `max_output_chars` (128-300,000, default 30,000) and truncation v1.0.8 adds `max_output_chars` (128-300,000, default 30,000) and truncation
metadata. Never pass the new argument to an older server; redirect verbose metadata. The repository pins v1.0.8 (`AGENT_SANDBOX_VERSION`), so the
output and read a file instead. documented deployment is bounded; never pass the new argument to an older
server, and on one redirect verbose output and read a file instead.
Deployment, connection matrix and DSH row: Deployment, connection matrix and DSH row:
[MCP deployment](references/mcp-deployment.md). Limits and cleanup: [MCP deployment](references/mcp-deployment.md). Limits and cleanup:
@@ -125,13 +126,14 @@ Read `docs/examples.md` and the testing table in `AGENTS.md` before editing.
alternatives, never a stack. alternatives, never a stack.
- **New example:** follow `docs/examples.md`, add the index entry, keep scripts - **New example:** follow `docs/examples.md`, add the index entry, keep scripts
executable/idempotent/self-cleaning, run `make check`. executable/idempotent/self-cleaning, run `make check`.
- **Version bump:** edit `versions.env` only, update prerequisites, run - **Version bump:** use `make set-version`; never edit pins by hand. Update
`make check` and the e2e. Never mix a bump with an unrelated feature. prerequisites, run `make check` and the e2e on a cluster. Never mix a bump
with an unrelated feature.
- **MCP integration:** pin the build to the cluster's tag, least-privilege SA, - **MCP integration:** pin the build to the cluster's tag, least-privilege SA,
ClusterIP + auth proxy, raised DSH timeout, no secrets in the repo. ClusterIP + auth proxy, raised DSH timeout, no secrets in the repo.
- **Optional hardening:** gVisor, `nodeSelector`, NetworkPolicy and extra - **Optional hardening:** gVisor, `nodeSelector`, NetworkPolicy, extra
provider keys stay opt-in; the sandbox policy lives under provider keys and failed-Pod recovery stay opt-in; the sandbox policy lives
`manifests/optional/`. All four are documented in under `manifests/optional/`. All of them are documented in
`examples/dsh-agents-as-a-service/docs/optional-hardening.md`. `examples/dsh-agents-as-a-service/docs/optional-hardening.md`.
- **Docs:** update the affected doc in the same change. - **Docs:** update the affected doc in the same change.

View File

@@ -74,6 +74,10 @@ DSH template.
Never pass `max_output_chars` to a pre-v1.0.8 server. On older servers, redirect Never pass `max_output_chars` to a pre-v1.0.8 server. On older servers, redirect
verbose output to a file and `download_file` it, or pipe through `tail`/`head`. verbose output to a file and `download_file` it, or pipe through `tail`/`head`.
The repository pins `AGENT_SANDBOX_VERSION=v1.0.8`, so the right-hand column is
what the documented platform and a same-tag MCP build provide; keep the
before-column for clusters still on v1.0.7.
## Safety and cleanup ## Safety and cleanup
- No auth: anyone reaching `:8000` gets every tool and the service account's - No auth: anyone reaching `:8000` gets every tool and the service account's

View File

@@ -28,8 +28,8 @@ a missing CRD is a prerequisite failure, not an application bug.
| `SandboxWarmPool` | Pre-warmed spares from one template | `spec.replicas`, `sandboxTemplateRef` | | `SandboxWarmPool` | Pre-warmed spares from one template | `spec.replicas`, `sandboxTemplateRef` |
| `SandboxClaim` | One tenant/user request; adopts a spare | `spec.warmPoolRef`, `additionalPodMetadata`, `status.sandbox.name` | | `SandboxClaim` | One tenant/user request; adopts a spare | `spec.warmPoolRef`, `additionalPodMetadata`, `status.sandbox.name` |
| `Sandbox` | Adopted or directly created pod + Service + PVC | `spec.operatingMode`, `status.conditions`, `status.serviceFQDN`, `status.podIPs` | | `Sandbox` | Adopted or directly created pod + Service + PVC | `spec.operatingMode`, `status.conditions`, `status.serviceFQDN`, `status.podIPs` |
| `Pod` | The DSH Web process | `:3080`, PVC at `/home/node/.dsh` | | `Pod` | The DSH Web process | `:3080`, PVC at `/home/node/.dsh` and `/workspace` (`subPath: workspace`) |
| `PersistentVolumeClaim` | Per-sandbox state | `dsh-home-<sandbox>` | | `PersistentVolumeClaim` | Per-sandbox DSH state + agent workspace | `dsh-home-<sandbox>` |
| `Service` | Stable per-sandbox DNS | `<sandbox>`, `status.serviceFQDN` | | `Service` | Stable per-sandbox DNS | `<sandbox>`, `status.serviceFQDN` |
Delete a claim to cascade-delete its sandbox and PVC (account deletion). Never Delete a claim to cascade-delete its sandbox and PVC (account deletion). Never
@@ -123,6 +123,25 @@ kubectl -n "$NS" patch sandboxwarmpool "$POOL" --type merge -p '{"spec":{"replic
`0600`; the template uses `fsGroupChangePolicy: OnRootMismatch`. If a resumed `0600`; the template uses `fsGroupChangePolicy: OnRootMismatch`. If a resumed
sandbox crash-loops with a mode-660 error, suspend it, fix the file with a sandbox crash-loops with a mode-660 error, suspend it, fix the file with a
throwaway pod, patch the policy, then resume. throwaway pod, patch the policy, then resume.
- Agent files live at `/workspace`, the same PVC mounted with
`subPath: workspace`; it survives suspend/resume and dies with the PVC.
- The templates set `HOME=/workspace` (the image default is `/home/node`). DSH's
directory picker lists `$HOME` when it opens and has no start-path setting, so
this is what makes the folder explorer start on the persisted workspace; `~`
and `$HOME`-relative tool caches/config also resolve there. `DSH_HOME` remains
`/home/node/.dsh`, so state and credentials stay out of the workspace. Pod env
is stamped at sandbox creation: a sandbox created before the override keeps
the old `HOME` until it is recreated (or its `podTemplate` is patched and the
sandbox resumed). Verify with
`kubectl -n "$NS" exec "$POD" -- printenv HOME`.
- DSH confines bash with its own file sandbox (`bubblewrap`, else Landlock) and
fails closed with `SANDBOX_UNAVAILABLE` when neither works. The image ships
`bubblewrap`, but it is unsetuid and needs an unprivileged user namespace that
this hardened pod (all capabilities dropped, RuntimeDefault seccomp) does not
grant; that plus no Landlock runner is why the template sets
`DSH_PERMISSION_MODE=danger-full-access` and treats the pod as the boundary.
Never "fix" it by granting `CAP_SYS_ADMIN` or `seccompProfile: Unconfined`
without an explicit decision.
## 8. Gateway layer ## 8. Gateway layer

View File

@@ -18,8 +18,11 @@ events, then compare desired vs observed state. Keep output bounded. Never
| `ImagePullBackOff` | describe events | Wrong image/tag, private GHCR package, missing pull secret; on kind, load the image under the exact manifest ref. | | `ImagePullBackOff` | describe events | Wrong image/tag, private GHCR package, missing pull secret; on kind, load the image under the exact manifest ref. |
| `CrashLoopBackOff`, probe 401 | logs, `describe` | Probe path is `/`; use `/favicon.svg`. | | `CrashLoopBackOff`, probe 401 | logs, `describe` | Probe path is `/`; use `/favicon.svg`. |
| Restart with mode-660 error | logs mention `.credentials.yaml` | kubelet fsGroup widened the file; suspend, `chmod 600` from a throwaway pod, patch `fsGroupChangePolicy: OnRootMismatch`, resume. | | Restart with mode-660 error | logs mention `.credentials.yaml` | kubelet fsGroup widened the file; suspend, `chmod 600` from a throwaway pod, patch `fsGroupChangePolicy: OnRootMismatch`, resume. |
| Every bash command fails with `SANDBOX_UNAVAILABLE` | DSH tool result names the requested mode and backends | DSH's own command sandbox has no usable runner: `bubblewrap` ships in the image but is unsetuid and needs an unprivileged user namespace this hardened pod does not grant, and no Landlock runner is available. The example template sets `DSH_PERMISSION_MODE=danger-full-access`; do not grant capabilities to restore it unless the operator explicitly accepts that downgrade. |
| Folder picker opens on `/home/node`, not the workspace | `kubectl exec <pod> -- printenv HOME` | The sandbox predates the template's `HOME=/workspace` and pod env is stamped at creation. Recreate the sandbox (PVC survives) or patch its `podTemplate` with `HOME`, then resume. Workspace dotfiles (`.cache`, `.config`, `.npm`, `.agents`) are the expected trade-off of the override, not a fault. |
| PVC `Pending` | `kubectl describe pvc`, StorageClass | No default provisioner, access mode, zone binding. | | PVC `Pending` | `kubectl describe pvc`, StorageClass | No default provisioner, access mode, zone binding. |
| Launch token not found | `kubectl logs <pod> --tail=2000 | grep 'dsh web:'` | Pod not ready yet, or logs rotated. Do not guess tokens; wait for readiness. | | Launch token not found | `kubectl logs <pod> --tail=2000 | grep 'dsh web:'` | Pod not ready yet, or logs rotated. Do not guess tokens; wait for readiness. |
| Sandbox reports `Finished=True` | pod phase, `kubectl get events` | The backing Pod reached `Failed` (eviction, spot reclaim) and `failedPodPolicy` defaults to `Retain`. Delete the Pod to force a replacement, or set `Recreate` (v1.0.8+) on the Sandbox/template; the claim and PVCs survive either way. |
| Hand-applied sandbox disappeared | claim annotations, gateway logs | Idle sweeper only manages claims with `aaas.example.com/managed-by=dsh-gateway`; if it was managed, recreate/resume. | | Hand-applied sandbox disappeared | claim annotations, gateway logs | Idle sweeper only manages claims with `aaas.example.com/managed-by=dsh-gateway`; if it was managed, recreate/resume. |
| Gateway 401 | gateway logs, cookie flags | Missing/expired cookie, DSH handshake failed, or `COOKIE_SECURE=true` over HTTP. | | Gateway 401 | gateway logs, cookie flags | Missing/expired cookie, DSH handshake failed, or `COOKIE_SECURE=true` over HTTP. |
| Gateway 502/503 | sandbox Ready, gateway logs | Wake timeout, stale DNS, token not yet in logs; check `WAKE_TIMEOUT` and RBAC `pods/log`. | | Gateway 502/503 | sandbox Ready, gateway logs | Wake timeout, stale DNS, token not yet in logs; check `WAKE_TIMEOUT` and RBAC `pods/log`. |

View File

@@ -1,4 +1,6 @@
# Copy to .env and fill in. Never commit a real key. # Reference environment names for local runs. Nothing sources this file
# automatically: export the values or pass them with `docker run -e/--env-file`.
# Copy it to .env if you prefer --env-file. Never commit a real key.
# #
# The version values below are documentation only: versions.env is the single # The version values below are documentation only: versions.env is the single
# source of truth, and scripts/check-versions.sh fails if they drift. # source of truth, and scripts/check-versions.sh fails if they drift.
@@ -20,8 +22,11 @@ TOGETHER_API_KEY=
CEREBRAS_API_KEY= CEREBRAS_API_KEY=
AI_GATEWAY_API_KEY= AI_GATEWAY_API_KEY=
HF_TOKEN= HF_TOKEN=
DSH_VERSION=dsh-v0.2.1-rc.3 DSH_VERSION=dsh-v0.2.1-rc.5
RELEASE_REVISION=r4 # Full upstream commit SHA that DSH_VERSION resolves to; the main image build
# fails if the clone's HEAD differs (guards against a moved tag).
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
RELEASE_REVISION=r6
PNPM_VERSION=11.28.5 PNPM_VERSION=11.28.5
# Container port, and the host port published by "make run". # Container port, and the host port published by "make run".
DSH_PORT=3080 DSH_PORT=3080
@@ -29,22 +34,7 @@ DSH_PORT=3080
# public hostname). localhost and 127.0.0.1 are always trusted. # public hostname). localhost and 127.0.0.1 are always trusted.
DSH_TRUSTED_HOSTS= DSH_TRUSTED_HOSTS=
# --- agents-as-a-service example --- # The example gateway's runtime settings (NAMESPACE, POOL, IDLE_TIMEOUT,
NAMESPACE=dsh-demo # WAKE_TIMEOUT, COOKIE_*, AUTH_PROXY_HEADER, ...) are documented in
POOL=dsh-pool # examples/dsh-agents-as-a-service/gateway/README.md and set in
# Seconds a sandbox may stay idle before the sweeper suspends it, and the max # examples/dsh-agents-as-a-service/manifests/50-gateway.yaml.
# time a proxied request waits for a suspended sandbox to become Ready.
IDLE_TIMEOUT=360
WAKE_TIMEOUT=240
DSH_CONTAINER=dsh-web
DSH_LOG_TAIL_LINES=2000
COOKIE_NAME=dsh_gw_session
# Set to true only behind HTTPS; secure cookies are not sent over plain HTTP
# (localhost counts as a secure context in browsers).
COOKIE_SECURE=false
COOKIE_MAX_AGE=604800
MAX_BODY_MB=32
HTTP_TIMEOUT=300
# SSO is opt-in. Set this only when the gateway Service is reachable
# exclusively through oauth2-proxy (see the example exposure docs).
# AUTH_PROXY_HEADER=X-Forwarded-User

View File

@@ -28,7 +28,7 @@ body:
id: image-tag id: image-tag
attributes: attributes:
label: Image or release label: Image or release
description: The pinned tag or digest, for example dsh-v0.2.1-rc.3-r4. description: The tag or digest you deployed (from the manifest or release metadata).
- type: input - type: input
id: cluster id: cluster
attributes: attributes:

View File

@@ -19,8 +19,7 @@
## Checklist ## Checklist
- [ ] make check passes (versions, example layout, manifest YAML) - [ ] make check passes (versions, example layout, manifest YAML)
- [ ] make test passes (gateway unit tests) - [ ] make test / make lint pass when the change touches code (gateway unit tests, ruff, shellcheck; yamllint/actionlint where installed)
- [ ] make lint passes (ruff, shellcheck; yamllint/actionlint where installed)
- [ ] Docs updated for the change (root README, docs/, example README) - [ ] Docs updated for the change (root README, docs/, example README)
- [ ] No secrets or usable credentials committed - [ ] No secrets or usable credentials committed
- [ ] CHANGELOG.md was not edited by hand (generated by git-cliff) - [ ] CHANGELOG.md was not edited by hand (generated by git-cliff)

View File

@@ -53,6 +53,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare environment - name: Prepare environment
env: env:
@@ -60,14 +62,23 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-') pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')
echo "PLATFORM_PAIR=$pair" >> "$GITHUB_ENV" # This reusable workflow runs once for the main image and once for the
echo "CACHE_SCOPE=$(printf '%s' "$IMAGE" | tr '/' '-')" >> "$GITHUB_ENV" # gateway in the same release run. Artifact names must include a
# unique token for the image: the repository path is a prefix of the
# gateway path, so a path-derived pattern would match both. Use a
# short hash of the full image name.
image_hash=$(printf '%s' "$IMAGE" | sha256sum | cut -c1-12)
{
echo "PLATFORM_PAIR=$pair"
echo "CACHE_SCOPE=$(printf '%s' "$IMAGE" | tr '/' '-')"
echo "ARTIFACT_PREFIX=$image_hash"
} >> "$GITHUB_ENV"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -101,7 +112,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.ARTIFACT_PREFIX }}-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/* path: /tmp/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -112,18 +123,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Prepare environment
env:
IMAGE: ${{ inputs.image }}
run: |
set -euo pipefail
echo "ARTIFACT_PREFIX=$(printf '%s' "$IMAGE" | sha256sum | cut -c1-12)" >> "$GITHUB_ENV"
- name: Download digests - name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digests-* pattern: digests-${{ env.ARTIFACT_PREFIX }}-*
merge-multiple: true merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -12,11 +12,9 @@ on:
- .github/workflows/build-gateway.yaml - .github/workflows/build-gateway.yaml
- .github/workflows/_build-image.yaml - .github/workflows/_build-image.yaml
# Write scopes are granted to the build job only; every other job stays read-only.
permissions: permissions:
contents: read contents: read
packages: write
id-token: write
attestations: write
concurrency: concurrency:
group: build-gateway-${{ github.ref }} group: build-gateway-${{ github.ref }}

View File

@@ -13,11 +13,9 @@ on:
- .github/workflows/build-main.yaml - .github/workflows/build-main.yaml
- .github/workflows/_build-image.yaml - .github/workflows/_build-image.yaml
# Write scopes are granted to the build job only; every other job stays read-only.
permissions: permissions:
contents: read contents: read
packages: write
id-token: write
attestations: write
concurrency: concurrency:
group: build-main-${{ github.ref }} group: build-main-${{ github.ref }}
@@ -31,6 +29,7 @@ jobs:
outputs: outputs:
identity: ${{ steps.meta.outputs.identity }} identity: ${{ steps.meta.outputs.identity }}
dsh_version: ${{ steps.meta.outputs.dsh_version }} dsh_version: ${{ steps.meta.outputs.dsh_version }}
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
pnpm_version: ${{ steps.meta.outputs.pnpm_version }} pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
release_revision: ${{ steps.meta.outputs.release_revision }} release_revision: ${{ steps.meta.outputs.release_revision }}
ghcr_image: ${{ steps.meta.outputs.ghcr_image }} ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
@@ -55,6 +54,7 @@ jobs:
{ {
echo "identity=$identity" echo "identity=$identity"
echo "dsh_version=$DSH_VERSION" echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION" echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=$RELEASE_REVISION" echo "release_revision=$RELEASE_REVISION"
echo "ghcr_image=$GHCR_IMAGE" echo "ghcr_image=$GHCR_IMAGE"
@@ -77,6 +77,7 @@ jobs:
dockerfile: Dockerfile dockerfile: Dockerfile
build-args: | build-args: |
DSH_VERSION=${{ needs.meta.outputs.dsh_version }} DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }} RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }} PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}

128
.github/workflows/ci-image.yaml vendored Normal file
View File

@@ -0,0 +1,128 @@
# Build-only check for the main DSH Web image: linux/amd64, no registry login,
# no push. It proves the Dockerfile still builds from the pinned versions.env and
# that the resulting image can actually run DSH Web. The published multi-arch
# build stays in build-main.yaml (merge to main) and release.yaml (tags).
#
# Like ci-python.yaml and ci-manifests.yaml this is path-filtered, so it is an
# on-demand check rather than a required status check.
name: CI image
on:
pull_request:
paths:
- Dockerfile
- .dockerignore
- versions.env
- .github/workflows/ci-image.yaml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-image-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-amd64:
name: build-amd64
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Read versions.env
id: meta
shell: bash
run: |
set -euo pipefail
set -a
# shellcheck disable=SC1091
source versions.env
set +a
identity="$DSH_VERSION"
[ -z "${RELEASE_REVISION:-}" ] || identity="$DSH_VERSION-$RELEASE_REVISION"
{
echo "identity=$identity"
echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=${RELEASE_REVISION:-}"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build linux/amd64 (load locally, never push)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: false
load: true
tags: dsh-web:ci
build-args: |
DSH_VERSION=${{ steps.meta.outputs.dsh_version }}
DSH_COMMIT=${{ steps.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ steps.meta.outputs.release_revision }}
PNPM_VERSION=${{ steps.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ steps.meta.outputs.identity }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha,scope=ci-image-linux-amd64
cache-to: type=gha,mode=max,scope=ci-image-linux-amd64
- name: Smoke test the image
env:
IDENTITY: ${{ steps.meta.outputs.identity }}
DSH_VERSION: ${{ steps.meta.outputs.dsh_version }}
run: |
set -euo pipefail
image=dsh-web:ci
# The packaged CLI runs and reports the version pinned in versions.env.
version="$(docker run --rm --entrypoint node "$image" /app/lib/bin.js --version)"
printf 'DSH CLI: %s\n' "$version"
grep -q "${DSH_VERSION#dsh-v}" <<<"$version"
# The container runs as the non-root node user and its writable
# directories exist and are owned by that user.
[ "$(docker run --rm --entrypoint id "$image" -u)" = "1000" ]
docker run --rm --entrypoint sh "$image" -c \
'test -d /workspace && test -d "$DSH_HOME" && [ "$(stat -c %u /workspace)" = 1000 ]'
# The command-sandbox runner this image is expected to ship.
docker run --rm --entrypoint bwrap "$image" --version
# Labels come from the build args rather than a hard-coded value.
label="$(docker inspect -f '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image")"
printf 'image version label: %s\n' "$label"
[ "$label" = "$IDENTITY" ]
- name: Serve DSH Web
run: |
set -euo pipefail
docker run -d --name dsh-ci -p 18080:3080 \
-e DSH_TRUSTED_HOSTS=127.0.0.1:18080 dsh-web:ci >/dev/null
trap 'docker rm -f dsh-ci >/dev/null 2>&1 || true' EXIT
# /favicon.svg is unauthenticated and served by the web layer, which is
# why the shipped probes use it too.
for second in $(seq 1 60); do
if curl -fsS -o /dev/null http://127.0.0.1:18080/favicon.svg; then
printf 'DSH Web served /favicon.svg after ~%ss\n' "$((second * 2))"
exit 0
fi
sleep 2
done
echo "::error::DSH Web did not serve /favicon.svg within 120s"
# Pod logs can hold the DSH launch token: never dump them verbatim.
docker logs dsh-ci 2>&1 | grep -v 'token=' | tail -50 || true
exit 1

88
.github/workflows/ci-manifests.yaml vendored Normal file
View File

@@ -0,0 +1,88 @@
# Manifest shape validation and server-side dry-run. Split from ci.yaml so this
# job can use native on.pull_request.paths filtering and skip PRs that do not
# touch manifests, scripts or versions.env. The job name is `manifests`; because
# it is path-filtered it is an on-demand check, not a required branch-protection
# check (see docs/gitflow.md).
name: CI (manifests)
on:
pull_request:
paths:
- examples/**/manifests/**
- scripts/check-manifests.py
- versions.env
- .github/workflows/ci-manifests.yaml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-manifests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
manifests:
name: manifests
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Create kind cluster
uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1
with:
version: v0.33.0
cluster_name: dsh-ci
- name: Read versions.env
run: |
set -euo pipefail
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
echo "$line" >> "$GITHUB_ENV"
done < versions.env
- name: Install agent-sandbox and Gateway API CRDs
run: |
set -euo pipefail
# Download, verify the pinned digest from versions.env, then apply.
curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \
"https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c -
kubectl apply -f /tmp/agent-sandbox.yaml
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxclaims.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxes.agents.x-k8s.io --timeout=180s
curl -fsSL --proto '=https' -o /tmp/gateway-api.yaml \
"https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml"
printf '%s %s\n' "$GATEWAY_API_MANIFEST_SHA256" /tmp/gateway-api.yaml | sha256sum -c -
kubectl apply -f /tmp/gateway-api.yaml
kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=180s
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Install check dependencies
run: pip install "pyyaml==6.0.3"
- name: Validate manifest shape
run: python scripts/check-manifests.py
- name: Validate manifests (server dry-run)
run: |
set -euo pipefail
kubectl create namespace dsh-demo
shopt -s globstar nullglob
count=0
for file in examples/*/manifests/**/*.yaml; do
case "$file" in *.yaml.example) continue ;; esac
echo "== $file =="
kubectl apply --dry-run=server -f "$file"
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "no manifests were validated" >&2
exit 1
fi
echo "validated $count manifests"

75
.github/workflows/ci-python.yaml vendored Normal file
View File

@@ -0,0 +1,75 @@
# Gateway unit tests and lint. Split from ci.yaml so this job can use native
# on.pull_request.paths filtering and skip PRs that do not touch the gateway.
# The job name is `python`; because it is path-filtered it is an on-demand check,
# not a required branch-protection check (see docs/gitflow.md).
name: CI (python)
on:
pull_request:
paths:
- examples/dsh-agents-as-a-service/gateway/**
- .github/workflows/ci-python.yaml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-python-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
name: python
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: examples/dsh-agents-as-a-service/gateway
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
examples/dsh-agents-as-a-service/gateway/requirements.txt
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
- name: Install dependencies
run: |
set -euo pipefail
python -m pip install --upgrade pip
# Both files are fully hash-pinned (uv pip compile --generate-hashes);
# pip refuses to install anything not listed with a matching hash.
pip install --require-hashes -r requirements.txt -r requirements-dev.txt
- name: Lint
run: |
set -euo pipefail
python -m ruff check .
python -m ruff format --check .
- name: Unit tests with coverage
run: >-
python -m pytest -q
--cov=. --cov-report=xml:coverage.xml --cov-report=term
--junitxml=junit.xml
- name: Coverage summary
run: |
set -euo pipefail
{
echo "### Gateway test coverage"
python -m coverage report
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload test artefacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gateway-test-results
path: |
examples/dsh-agents-as-a-service/gateway/junit.xml
examples/dsh-agents-as-a-service/gateway/coverage.xml
if-no-files-found: warn
retention-days: 7

View File

@@ -1,12 +1,11 @@
# Continuous integration: lint, unit tests, manifest dry-run, gateway image # Continuous integration: lint and dependency review. Job names are the required
# smoke test and dependency review. Job names are the required branch-protection # branch-protection checks documented in docs/gitflow.md. The gateway unit tests
# checks documented in docs/gitflow.md. # and the manifest dry-run live in ci-python.yaml / ci-manifests.yaml so each can
# use native on.pull_request.paths filtering and skip unrelated changes.
name: CI name: CI
on: on:
pull_request: pull_request:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -38,19 +37,29 @@ jobs:
- name: Install actionlint - name: Install actionlint
env: env:
ACTIONLINT_VERSION: v1.7.12 ACTIONLINT_VERSION: v1.7.12
# SHA-256 of actionlint_1.7.12_linux_amd64.tar.gz, published in the
# release's actionlint_1.7.12_checksums.txt. Update it with the version.
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: | run: |
set -euo pipefail set -euo pipefail
version="${ACTIONLINT_VERSION#v}" version="${ACTIONLINT_VERSION#v}"
curl -fsSL -o /tmp/actionlint.tar.gz "https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/actionlint_${version}_linux_amd64.tar.gz" asset="actionlint_${version}_linux_amd64.tar.gz"
tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint curl -fsSL --proto '=https' -o "/tmp/$asset" \
"https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/$asset"
printf '%s %s\n' "$ACTIONLINT_SHA256" "/tmp/$asset" | sha256sum -c -
tar -xzf "/tmp/$asset" -C /tmp actionlint
install -m 0755 /tmp/actionlint /usr/local/bin/actionlint install -m 0755 /tmp/actionlint /usr/local/bin/actionlint
actionlint -version actionlint -version
- name: Install hadolint - name: Install hadolint
env: env:
HADOLINT_VERSION: v2.12.0 HADOLINT_VERSION: v2.12.0
# SHA-256 of hadolint-Linux-x86_64, published next to the download.
HADOLINT_SHA256: 56de6d5e5ec427e17b74fa48d51271c7fc0d61244bf5c90e828aab8362d55010
run: | run: |
set -euo pipefail set -euo pipefail
curl -fsSL -o /tmp/hadolint "https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64" curl -fsSL --proto '=https' -o /tmp/hadolint \
"https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64"
printf '%s %s\n' "$HADOLINT_SHA256" /tmp/hadolint | sha256sum -c -
install -m 0755 /tmp/hadolint /usr/local/bin/hadolint install -m 0755 /tmp/hadolint /usr/local/bin/hadolint
hadolint --version hadolint --version
- name: Repository checks - name: Repository checks
@@ -58,7 +67,6 @@ jobs:
set -euo pipefail set -euo pipefail
./scripts/check-versions.sh ./scripts/check-versions.sh
./scripts/check-examples.sh ./scripts/check-examples.sh
python scripts/check-manifests.py
- name: Shellcheck - name: Shellcheck
run: shellcheck -x build.sh scripts/*.sh examples/*/scripts/*.sh run: shellcheck -x build.sh scripts/*.sh examples/*/scripts/*.sh
- name: Actionlint - name: Actionlint
@@ -77,169 +85,6 @@ jobs:
--offline --no-progress --verbose --offline --no-progress --verbose
./README.md ./CONTRIBUTING.md ./SECURITY.md ./AGENTS.md ./docs ./examples ./README.md ./CONTRIBUTING.md ./SECURITY.md ./AGENTS.md ./docs ./examples
python:
name: python
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: examples/dsh-agents-as-a-service/gateway
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
examples/dsh-agents-as-a-service/gateway/requirements.txt
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
- name: Install dependencies
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
- name: Lint
run: |
set -euo pipefail
python -m ruff check .
python -m ruff format --check .
- name: Unit tests with coverage
run: >-
python -m pytest -q
--cov=. --cov-report=xml:coverage.xml --cov-report=term
--junitxml=junit.xml
- name: Coverage summary
run: |
set -euo pipefail
{
echo "### Gateway test coverage"
python -m coverage report
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload test artefacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gateway-test-results
path: |
examples/dsh-agents-as-a-service/gateway/junit.xml
examples/dsh-agents-as-a-service/gateway/coverage.xml
if-no-files-found: warn
retention-days: 7
manifests:
name: manifests
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Create kind cluster
uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1
with:
version: v0.33.0
cluster_name: dsh-ci
- name: Read versions.env
run: |
set -euo pipefail
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
echo "$line" >> "$GITHUB_ENV"
done < versions.env
- name: Install agent-sandbox and Gateway API CRDs
run: |
set -euo pipefail
kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxclaims.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxes.agents.x-k8s.io --timeout=180s
kubectl apply -f "https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml"
kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=180s
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Install check dependencies
run: pip install "pyyaml==6.0.3"
- name: Validate manifest shape
run: python scripts/check-manifests.py
- name: Validate manifests (server dry-run)
run: |
set -euo pipefail
kubectl create namespace dsh-demo
shopt -s globstar nullglob
count=0
for file in examples/*/manifests/**/*.yaml; do
case "$file" in *.yaml.example) continue ;; esac
echo "== $file =="
kubectl apply --dry-run=server -f "$file"
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "no manifests were validated" >&2
exit 1
fi
echo "validated $count manifests"
gateway-image:
name: gateway-image
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build gateway image
run: docker build -t dsh-aaas-gateway:ci examples/dsh-agents-as-a-service/gateway/
- name: Smoke-test /healthz without a cluster
run: |
set -euo pipefail
cat > /tmp/kubeconfig <<'EOF'
apiVersion: v1
kind: Config
clusters:
- name: dummy
cluster:
server: https://127.0.0.1:6443
insecure-skip-tls-verify: true
contexts:
- name: dummy
context:
cluster: dummy
user: dummy
users:
- name: dummy
user: {}
current-context: dummy
EOF
docker run -d --name gateway-smoke \
-p 18080:8080 \
-e NAMESPACE=dsh-demo \
-e POOL=dsh-pool \
-e KUBECONFIG=/tmp/kubeconfig \
-v /tmp/kubeconfig:/tmp/kubeconfig:ro \
dsh-aaas-gateway:ci
trap 'docker logs gateway-smoke || true; docker rm -f gateway-smoke || true' EXIT
code=""
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/healthz || true)
[ "$code" = "200" ] && break
sleep 1
done
if [ "$code" != "200" ]; then
echo "gateway /healthz returned '$code' instead of 200" >&2
exit 1
fi
echo "gateway image healthy: GET /healthz -> 200"
dependency-review: dependency-review:
name: dependency-review name: dependency-review
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'

View File

@@ -58,6 +58,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Add any setup steps before running the `github/codeql-action/init` action. # Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node` # This includes steps like installing compilers or runtimes (`actions/setup-node`

View File

@@ -40,7 +40,10 @@ jobs:
- name: Install agent-sandbox - name: Install agent-sandbox
run: | run: |
set -euo pipefail set -euo pipefail
kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml" curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \
"https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c -
kubectl apply -f /tmp/agent-sandbox.yaml
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
@@ -66,7 +69,9 @@ jobs:
echo "== events ==" echo "== events =="
kubectl get events -n dsh-demo --sort-by=.lastTimestamp 2>&1 || true kubectl get events -n dsh-demo --sort-by=.lastTimestamp 2>&1 || true
echo "== gateway logs ==" echo "== gateway logs =="
kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 || true # Redact any token material before this file becomes an artifact.
kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 \
| sed -E 's/(token=)[^&"[:space:]]+/\1[REDACTED]/g' || true
} > /tmp/e2e-cluster.txt } > /tmp/e2e-cluster.txt
- name: Upload cluster state - name: Upload cluster state
if: always() if: always()

View File

@@ -16,17 +16,11 @@ on:
description: Existing tag to release (must match versions.env) description: Existing tag to release (must match versions.env)
required: true required: true
type: string type: string
publish_legacy_gateway_alias:
description: Also publish the legacy :gateway alias (one release only)
required: false
default: true
type: boolean
# Read-only by default; the build and publish jobs add the write scopes they
# need as job-level grants.
permissions: permissions:
contents: write contents: read
packages: write
id-token: write
attestations: write
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
@@ -40,11 +34,13 @@ jobs:
outputs: outputs:
identity: ${{ steps.meta.outputs.identity }} identity: ${{ steps.meta.outputs.identity }}
dsh_version: ${{ steps.meta.outputs.dsh_version }} dsh_version: ${{ steps.meta.outputs.dsh_version }}
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
pnpm_version: ${{ steps.meta.outputs.pnpm_version }} pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
release_revision: ${{ steps.meta.outputs.release_revision }} release_revision: ${{ steps.meta.outputs.release_revision }}
ghcr_image: ${{ steps.meta.outputs.ghcr_image }} ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
gateway_image: ${{ steps.meta.outputs.gateway_image }} gateway_image: ${{ steps.meta.outputs.gateway_image }}
tag: ${{ steps.meta.outputs.tag }} tag: ${{ steps.meta.outputs.tag }}
run_tests: ${{ steps.meta.outputs.run_tests }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -55,13 +51,17 @@ jobs:
- name: Validate tag against versions.env - name: Validate tag against versions.env
id: meta id: meta
shell: bash shell: bash
env:
# Passed through the environment, never interpolated into the script:
# a tag name or dispatch input can contain shell metacharacters.
REQUESTED_TAG: ${{ inputs.tag || github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source versions.env source versions.env
set +a set +a
tag="${{ inputs.tag || github.ref_name }}" tag="$REQUESTED_TAG"
identity="$DSH_VERSION" identity="$DSH_VERSION"
[ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION" [ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION"
if [ "$tag" != "$identity" ]; then if [ "$tag" != "$identity" ]; then
@@ -82,22 +82,44 @@ jobs:
echo "tag commit $commit is not on main or a release branch" >&2 echo "tag commit $commit is not on main or a release branch" >&2
exit 1 exit 1
fi fi
# Tests already ran in PR CI when the tag is on main; only re-run
# them for release-branch tags (where ci.yaml does not trigger).
run_tests=true
if git rev-parse -q --verify origin/main >/dev/null && git merge-base --is-ancestor "$commit" origin/main; then
run_tests=false
fi
else else
echo "tag '$tag' does not exist in this repository" >&2 echo "tag '$tag' does not exist in this repository" >&2
exit 1 exit 1
fi fi
if ! git ls-remote --tags --exit-code "$DSH_REPO" "$DSH_VERSION" >/dev/null; then if ! upstream="$(git ls-remote --tags --exit-code "$DSH_REPO" \
"refs/tags/$DSH_VERSION^{}" "refs/tags/$DSH_VERSION")"; then
echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2 echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2
exit 1 exit 1
fi fi
# Never release a ref that no longer resolves to the pinned commit: an
# annotated tag has a peeled ^{} line, a lightweight tag only the ref.
resolved="$(printf '%s\n' "$upstream" \
| awk -v t="refs/tags/$DSH_VERSION^{}" '$2 == t {print $1}' | head -n1)"
if [ -z "$resolved" ]; then
resolved="$(printf '%s\n' "$upstream" \
| awk -v t="refs/tags/$DSH_VERSION" '$2 == t {print $1}' | head -n1)"
fi
if [ "$resolved" != "$DSH_COMMIT" ]; then
echo "upstream $DSH_REPO@$DSH_VERSION resolved to '$resolved'," \
"versions.env pins DSH_COMMIT=$DSH_COMMIT" >&2
exit 1
fi
{ {
echo "identity=$identity" echo "identity=$identity"
echo "dsh_version=$DSH_VERSION" echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION" echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=$RELEASE_REVISION" echo "release_revision=$RELEASE_REVISION"
echo "ghcr_image=$GHCR_IMAGE" echo "ghcr_image=$GHCR_IMAGE"
echo "gateway_image=$GATEWAY_IMAGE" echo "gateway_image=$GATEWAY_IMAGE"
echo "tag=$tag" echo "tag=$tag"
echo "run_tests=$run_tests"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Verify repository pins - name: Verify repository pins
@@ -106,6 +128,9 @@ jobs:
tests: tests:
name: Unit tests and lint name: Unit tests and lint
needs: meta needs: meta
# The test steps are guarded individually rather than the whole job so a
# main-branch tag (run_tests=false) still leaves this job "successful" and
# does not cascade-skip build-main/build-gateway.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
defaults: defaults:
@@ -117,6 +142,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
if: needs.meta.outputs.run_tests == 'true'
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: "3.12" python-version: "3.12"
@@ -125,16 +151,19 @@ jobs:
examples/dsh-agents-as-a-service/gateway/requirements.txt examples/dsh-agents-as-a-service/gateway/requirements.txt
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
- name: Install dependencies - name: Install dependencies
if: needs.meta.outputs.run_tests == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt pip install --require-hashes -r requirements.txt -r requirements-dev.txt
- name: Lint - name: Lint
if: needs.meta.outputs.run_tests == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
python -m ruff check . python -m ruff check .
python -m ruff format --check . python -m ruff format --check .
- name: Unit tests - name: Unit tests
if: needs.meta.outputs.run_tests == 'true'
run: python -m pytest -q run: python -m pytest -q
build-main: build-main:
@@ -153,6 +182,7 @@ jobs:
dockerfile: Dockerfile dockerfile: Dockerfile
build-args: | build-args: |
DSH_VERSION=${{ needs.meta.outputs.dsh_version }} DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }} RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }} PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}
@@ -178,33 +208,14 @@ jobs:
${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }} ${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }}
${{ needs.meta.outputs.gateway_image }}:latest ${{ needs.meta.outputs.gateway_image }}:latest
legacy-gateway-alias:
name: Publish legacy :gateway alias (temporary)
needs: [meta, build-gateway]
if: github.event_name != 'workflow_dispatch' || inputs.publish_legacy_gateway_alias
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Retag gateway as the legacy package tag
env:
SOURCE: ${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }}
LEGACY: ${{ needs.meta.outputs.ghcr_image }}:gateway
run: |
set -euo pipefail
docker buildx imagetools create -t "$LEGACY" "$SOURCE"
echo "Deprecated alias $LEGACY now points at $SOURCE" >> "$GITHUB_STEP_SUMMARY"
publish-release: publish-release:
name: Changelog and GitHub Release name: Changelog and GitHub Release
needs: [meta, build-main, build-gateway] needs: [meta, build-main, build-gateway]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
permissions:
contents: write
pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -214,11 +225,17 @@ jobs:
- name: Install git-cliff - name: Install git-cliff
env: env:
GIT_CLIFF_VERSION: v2.14.2 GIT_CLIFF_VERSION: v2.14.2
# SHA-256 of git-cliff-2.14.2-x86_64-unknown-linux-gnu.tar.gz; upstream
# publishes the matching SHA-512 next to the asset. Update with the version.
GIT_CLIFF_SHA256: 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4
run: | run: |
set -euo pipefail set -euo pipefail
version="${GIT_CLIFF_VERSION#v}" version="${GIT_CLIFF_VERSION#v}"
curl -fsSL -o /tmp/git-cliff.tar.gz "https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/git-cliff-$version-x86_64-unknown-linux-gnu.tar.gz" asset="git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz"
tar -xzf /tmp/git-cliff.tar.gz -C /tmp curl -fsSL --proto '=https' -o "/tmp/$asset" \
"https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/$asset"
printf '%s %s\n' "$GIT_CLIFF_SHA256" "/tmp/$asset" | sha256sum -c -
tar -xzf "/tmp/$asset" -C /tmp
install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff
git-cliff --version git-cliff --version
@@ -248,6 +265,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IDENTITY: ${{ needs.meta.outputs.identity }} IDENTITY: ${{ needs.meta.outputs.identity }}
continue-on-error: true
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"

View File

@@ -9,8 +9,8 @@ before changing anything; it encodes the conventions the CI enforces.
Harness Web (dsh web) as a slim, non-root, multi-arch image. Everything else Harness Web (dsh web) as a slim, non-root, multi-arch image. Everything else
exists to support it or to demonstrate an implementation on top of it. exists to support it or to demonstrate an implementation on top of it.
- **Every implementation is a self-contained example** under examples/<name>/. - **Every implementation is a self-contained example** under examples/<name>/.
There is exactly one example for the agents-as-a-service platform; do not The multi-tenant platform lives in exactly one directory; do not split it into
split it into parallel directories. parallel examples.
- **versions.env is the single source of truth** for the DSH ref, pnpm, the - **versions.env is the single source of truth** for the DSH ref, pnpm, the
control-plane versions and the image names. control-plane versions and the image names.
- **Docs describe the shipped behavior**, not intentions. If you change a - **Docs describe the shipped behavior**, not intentions. If you change a
@@ -43,18 +43,18 @@ before changing anything; it encodes the conventions the CI enforces.
| versions.env | Every pinned version and image name | | versions.env | Every pinned version and image name |
| Makefile | Canonical local entrypoint | | Makefile | Canonical local entrypoint |
| build.sh | Deprecated shim that forwards to make | | build.sh | Deprecated shim that forwards to make |
| docs/ | main-image.md, examples.md, gitflow.md, versioning.md | | docs/ | main-image.md, examples.md, gitflow.md, versioning.md (index: README.md) |
| examples/ | One implementation per directory (contract in docs/examples.md) | | examples/ | One implementation per directory (contract in docs/examples.md) |
| examples/dsh-agents-as-a-service/ | The multi-tenant platform example (manifests, gateway, docs, scripts) | | examples/dsh-agents-as-a-service/ | The multi-tenant platform example (manifests, gateway, docs, scripts) |
| examples/minimal-single-tenant/ | The just-one-DSH-Web quickstart | | examples/minimal-single-tenant/ | The just-one-DSH-Web quickstart |
| scripts/ | check-versions.sh, check-examples.sh, check-manifests.py, set-version.sh | | scripts/ | check-versions.sh, check-examples.sh, check-manifests.py, set-version.sh |
| .dsh/skills/ | Project-local DSH skills (auto-discovered); dsh-agent-sandbox-platform covers platform operation, MCP limits and extension recipes | | .dsh/skills/ | Project-local DSH skills (auto-discovered); dsh-agent-sandbox-platform covers platform operation, MCP limits and extension recipes |
| .github/workflows/ | ci.yaml, reusable _build-image.yaml, build-*, release.yaml, e2e-kind.yaml, pr-title.yaml, codeql.yml | | .github/workflows/ | ci.yaml (lint, dependency-review), ci-python.yaml, ci-manifests.yaml, ci-image.yaml (amd64 build + smoke test on Dockerfile/versions.env changes), reusable _build-image.yaml, build-*, release.yaml, e2e-kind.yaml, pr-title.yaml, codeql.yml |
## Commands ## Commands
make check # versions, example layout and manifest YAML (run before every commit) make check # versions, example layout and manifest YAML (run before every commit)
make test # gateway unit tests (109+ tests) make test # gateway unit tests (pytest)
make lint # ruff + shellcheck (+ yamllint/actionlint when installed) make lint # ruff + shellcheck (+ yamllint/actionlint when installed)
make image # build the main DSH Web image (slow: clones and builds DSH) make image # build the main DSH Web image (slow: clones and builds DSH)
make gateway # build the example gateway image (fast) make gateway # build the example gateway image (fast)
@@ -72,21 +72,18 @@ needs kind plus the agent-sandbox CRDs (see the example README).
| --- | --- | | --- | --- |
| Documentation only | make check; verify relative links | | Documentation only | make check; verify relative links |
| Example manifest | make check; kubectl apply --dry-run=server in CI | | Example manifest | make check; kubectl apply --dry-run=server in CI |
| Optional hardening (gVisor, nodeSelector, NetworkPolicy, provider keys) | make check; server dry-run in CI; manual verification on a capable cluster | | Optional hardening (gVisor, nodeSelector, NetworkPolicy, provider keys, failed-Pod recovery) | make check; server dry-run in CI; manual verification on a capable cluster |
| Gateway code | make test; make lint; add or update tests | | Gateway code | make test; make lint; add or update tests |
| Dockerfile / versions.env | make check; make image (amd64) and a container smoke test | | Dockerfile / versions.env | make check; the CI `build-amd64` job (`ci-image.yaml`) builds and smoke-tests the image on the PR; run `make image` locally only when you need the image |
| Workflow | actionlint in CI; keep actions SHA-pinned; no hard-coded versions | | Workflow | actionlint in CI; keep actions SHA-pinned; no hard-coded versions |
| New example | make check; all files described in docs/examples.md | | New example | make check; all files described in docs/examples.md |
| Project skill (.dsh/skills/) | make check; frontmatter and size check; shellcheck skill scripts; load it with the skill tool | | Project skill (.dsh/skills/) | make check; validate frontmatter; keep SKILL.md under ~8 KB; shellcheck skill scripts; load it with the skill tool |
## Versioning and releases ## Versioning and releases
- Release identity = DSH_VERSION, optionally with a repository revision suffix - Release identity = DSH_VERSION plus an optional -rN repository revision.
for re-releases at the same DSH ref (current identity: The current identity is dsh-v0.2.1-rc.5-r6 (first release at this DSH ref, so no
dsh-v0.2.1-rc.3-r4). suffix); a repository-only change at the same ref publishes -r2.
- The current identity is dsh-v0.2.1-rc.3-r4 (stored in versions.env). The
dsh-v0.2.1-rc.3 image tag already exists and tags are immutable, which is why
the first release under this scheme carries -r2.
- Channels: release tags are immutable; latest follows the newest release; - Channels: release tags are immutable; latest follows the newest release;
main is the development channel for both image packages. main is the development channel for both image packages.
- A broken release is fixed by publishing -r(N+1), never by re-tagging. - A broken release is fixed by publishing -r(N+1), never by re-tagging.
@@ -99,8 +96,9 @@ needs kind plus the agent-sandbox CRDs (see the example README).
- Branch names: feat/, fix/, docs/, chore/, refactor/, test/. - Branch names: feat/, fix/, docs/, chore/, refactor/, test/.
- Conventional Commit subjects and PR titles (feat(scope): ..., fix(scope): ...). - Conventional Commit subjects and PR titles (feat(scope): ..., fix(scope): ...).
- PRs are squash-merged; main stays linear and protected. - PRs are squash-merged; main stays linear and protected.
- Required checks: lint, python, manifests, gateway-image, pr-title, CodeQL, - Required on every PR: lint, pr-title, CodeQL (both Analyze jobs) and
dependency-review. dependency-review. python, manifests and build-amd64 are path-filtered
on-demand checks; run them when the change touches their scope.
- Keep diffs scoped; update docs with the code; never mix a version bump with - Keep diffs scoped; update docs with the code; never mix a version bump with
unrelated changes. unrelated changes.
@@ -108,9 +106,9 @@ needs kind plus the agent-sandbox CRDs (see the example README).
1. Read docs/examples.md and examples/README.md. 1. Read docs/examples.md and examples/README.md.
2. Create examples/<name>/ with README.md, manifests/NN-*.yaml, optional 2. Create examples/<name>/ with README.md, manifests/NN-*.yaml, optional
manifests/exposure/<variant>/ (plus README), optional components with their manifests/optional/<feature>/ or manifests/exposure/<variant>/ (each with a
own Dockerfile/README/tests, and scripts/ (executable, idempotent, README), optional components with their own Dockerfile/README/tests, and
self-cleaning). scripts/ (executable, idempotent, self-cleaning).
3. Publish component images under <registry>/<owner>/<repo>/<example>/<component>. 3. Publish component images under <registry>/<owner>/<repo>/<example>/<component>.
4. Add the example to examples/README.md; scripts/check-examples.sh enforces 4. Add the example to examples/README.md; scripts/check-examples.sh enforces
the layout. the layout.
@@ -119,16 +117,33 @@ needs kind plus the agent-sandbox CRDs (see the example README).
- AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is - AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is
reachable exclusively through the authenticating proxy; otherwise any client reachable exclusively through the authenticating proxy; otherwise any client
can spoof X-Forwarded-User. can spoof X-Forwarded-User. The SSO variant's NetworkPolicy is required for
production, and `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
verifies the setup (Secrets, gateway env, TLS reference, placeholders, second
paths).
- The example gateway reads the DSH launch token from pod logs. Treat pod logs - The example gateway reads the DSH launch token from pod logs. Treat pod logs
as sensitive and keep its RBAC namespace-scoped. as sensitive and keep its RBAC namespace-scoped.
- Browser sessions are opaque server-side ids held in gateway memory: they are
revocable (logout/delete/recreate/expiry) but a gateway restart logs everyone
out and horizontal scaling needs a shared store. Session cookies are never
accepted as bearer tokens.
- AUTH_RATE_LIMIT, SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and
WS_MAX_MESSAGE_BYTES are in-process abuse guards, not ingress rate limits,
quotas or an audit log. Add those for an internet-facing deployment.
- The example shares provider keys (dsh-provider-keys plus the optional - The example shares provider keys (dsh-provider-keys plus the optional
dsh-provider-keys-extra) across sandboxes; do not describe it as dsh-provider-keys-extra) across sandboxes; do not describe it as
tenant-isolated. tenant-isolated.
- gVisor, nodeSelector and the NetworkPolicies are opt-in and are not active in - gVisor, nodeSelector, the shipped sandbox/exposure NetworkPolicies and the
the base manifests; do not describe the base example as hardened. documented gateway egress policy, extra provider keys and failed-Pod recovery
- TLS terminates outside the gateway; set COOKIE_SECURE=true for HTTPS and keep are opt-in and are not active in the base manifests; do not describe the base
the base manifest safe for local HTTP. example as hardened.
The sandbox and gateway-egress policies are required production steps; an
exposure without its NetworkPolicy is demo-only. See the example's
production checklist.
- TLS terminates outside the gateway. Set COOKIE_SECURE=true for HTTPS and drop
ALLOW_INSECURE_COOKIES; the gateway refuses COOKIE_SECURE=false unless
ALLOW_INSECURE_COOKIES=1, which is only for the plain-HTTP local demo. Never
expose that combination.
## Ask before ## Ask before

View File

@@ -3,6 +3,67 @@
All notable changes to this repository are documented here. All notable changes to this repository are documented here.
Generated by git-cliff from Conventional Commits; do not edit this file by hand. Generated by git-cliff from Conventional Commits; do not edit this file by hand.
## [dsh-v0.2.1-rc.5-r6] - 2026-10-31
### Added
- **example:** Persist the agent workspace on the PVC via subPath (#34) (367294b)
- **example:** Start DSH's folder picker on the persisted workspace (#36) (82ee0d0)
### CI
- **workflows:** Streamline CI and remove redundant builds (#31) (dcb5b43)
### Documentation
- Align readmes, docs and agent guidance with current behavior (#37) (24f23e0)
### Fixed
- **gateway:** Answer client-aborted requests with 499, not a traceback (#32) (cbcc56f)
- Ship bubblewrap for DSH's command sandbox, keep agents usable without it (#35) (a09d4ac)
- **ci:** Harden workflow token scope and download integrity (#39) (2f959e6)
- **gateway:** Replace token cookies with revocable sessions and harden abuse controls (#40) (1a929e3)
### Maintenance
- **changelog:** Refresh for dsh-v0.2.1-rc.5 (#30) (b57916d)
- Bump docker/login-action (#28) (8c34f43)
- **deps:** Bump agent-sandbox to v1.0.9 (#38) (4c073ee)
- Improve package installation and add Python depend… (#42) (8e9bd47)
### Other
- Add continue-on-error to CHANGELOG PR step (97c5632)
## [dsh-v0.2.1-rc.5] - 2026-10-30
### Added
- **example:** Add optional failed-Pod recovery (#26) (14c3fb8)
### Fixed
- **ci:** Make release image digests unique and allow changelog PRs (#24) (2b43d5d)
### Maintenance
- **changelog:** Refresh for dsh-v0.2.1-rc.3-r4 (#23) (97abf75)
- **deps:** Bump agent-sandbox to v1.0.8 (#25) (96d3030)
- **release:** Dsh-v0.2.1-rc.5 (#29) (bed8e56)
## [dsh-v0.2.1-rc.3-r4] - 2026-10-26
### Added
- **skills:** Add agent-sandbox platform skill (#20) (bf24306)
- **example:** Add optional gVisor, nodeSelector, NetworkPolicy and provider keys (#21) (3e9ba38)
### Maintenance
- **changelog:** Refresh for dsh-v0.2.1-rc.3-r3 (#19) (8681412)
- **release:** Dsh-v0.2.1-rc.3-r4 (#22) (e2f1fe5)
## [dsh-v0.2.1-rc.3-r3] - 2026-10-25 ## [dsh-v0.2.1-rc.3-r3] - 2026-10-25
### Added ### Added

View File

@@ -43,8 +43,10 @@ Install the gateway dev dependencies once:
- Use a Conventional Commit title, for example feat(gateway): add wake metrics - Use a Conventional Commit title, for example feat(gateway): add wake metrics
or docs(examples): clarify SSO trust boundary. PR titles are linted. or docs(examples): clarify SSO trust boundary. PR titles are linted.
- Keep the diff scoped. Squash merge into main; history stays linear. - Keep the diff scoped. Squash merge into main; history stays linear.
- Required checks: lint, python, manifests, gateway-image, pr-title, CodeQL, - Required on every PR: lint, pr-title, CodeQL (both Analyze jobs) and
and dependency-review. Branch protection is described in docs/gitflow.md. dependency-review. python, manifests and build-amd64 are path-filtered
on-demand checks; run them when the change touches their scope. Branch
protection is described in docs/gitflow.md.
- Update the docs that the change affects; docs are part of the change, not a - Update the docs that the change affects; docs are part of the change, not a
follow-up. follow-up.
- Never commit a real secret. Secret-shaped YAML belongs in a *.yaml.example - Never commit a real secret. Secret-shaped YAML belongs in a *.yaml.example
@@ -60,16 +62,12 @@ it by hand; the release workflow regenerates it.
## Adding an example ## Adding an example
Read docs/examples.md, then add examples/<name>/ with: Read docs/examples.md, then add examples/<name>/ with a README.md,
numbered manifests/NN-*.yaml, optional manifests/optional/<feature>/ or
- README.md (what it demonstrates, prerequisites, steps, teardown) manifests/exposure/<variant>/ (each with a README), optional components (a
- manifests/NN-*.yaml (numbered, plain kubectl) Dockerfile plus README and tests), and executable, idempotent, self-cleaning
- optional manifests/exposure/<variant>/ with its own README.md scripts. Add the literal examples/<name>/ entry to examples/README.md;
- optional components (a Dockerfile plus README and tests per component) scripts/check-examples.sh enforces the layout.
- scripts/ with executable, idempotent, self-cleaning scripts
- docs/ for deeper material
List it in examples/README.md. scripts/check-examples.sh enforces this layout.
## Documentation ## Documentation

View File

@@ -6,13 +6,14 @@
# Build args (all pinned in versions.env, verified by scripts/check-versions.sh): # Build args (all pinned in versions.env, verified by scripts/check-versions.sh):
# DSH_REPO upstream git remote to package # DSH_REPO upstream git remote to package
# DSH_VERSION upstream git ref to build (tag or branch) # DSH_VERSION upstream git ref to build (tag or branch)
# DSH_COMMIT full commit SHA DSH_VERSION must resolve to
# RELEASE_REVISION optional repository revision label (r2, r3, ...) # RELEASE_REVISION optional repository revision label (r2, r3, ...)
# PNPM_VERSION pnpm used to build DSH # PNPM_VERSION pnpm used to build DSH
# VCS_REF this repository's commit, recorded in OCI labels # VCS_REF this repository's commit, recorded in OCI labels
# NODE_BUILD_IMAGE node image used to build DSH # NODE_BUILD_IMAGE node image used to build DSH (pinned by digest)
# NODE_RUNTIME_IMAGE node image used at runtime # NODE_RUNTIME_IMAGE node image used at runtime (pinned by digest)
ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build stage: clone DSH at the pinned ref, build, and package the CLI. # Build stage: clone DSH at the pinned ref, build, and package the CLI.
@@ -20,7 +21,8 @@ ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim
FROM $NODE_BUILD_IMAGE AS build FROM $NODE_BUILD_IMAGE AS build
ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
ARG DSH_VERSION=dsh-v0.2.1-rc.3 ARG DSH_VERSION=dsh-v0.2.1-rc.5
ARG DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
ARG PNPM_VERSION=11.28.5 ARG PNPM_VERSION=11.28.5
ENV CI=true \ ENV CI=true \
@@ -31,7 +33,15 @@ RUN corepack enable \
WORKDIR /src WORKDIR /src
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" . # Clone the pinned ref and fail closed unless it resolves to DSH_COMMIT. A moved
# tag would otherwise silently change what the image packages.
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" . \
&& actual="$(git -C /src rev-parse HEAD)" \
&& if [ "$actual" != "$DSH_COMMIT" ]; then \
echo "error: $DSH_REPO $DSH_VERSION resolved to $actual, expected DSH_COMMIT $DSH_COMMIT" >&2; \
echo "error: update DSH_VERSION/DSH_COMMIT in versions.env with scripts/set-version.sh" >&2; \
exit 1; \
fi
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -57,8 +67,8 @@ RUN cd /opt/dsh \
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM $NODE_RUNTIME_IMAGE AS runtime FROM $NODE_RUNTIME_IMAGE AS runtime
ARG DSH_VERSION=dsh-v0.2.1-rc.3 ARG DSH_VERSION=dsh-v0.2.1-rc.5
ARG RELEASE_REVISION=r4 ARG RELEASE_REVISION=r6
ARG PNPM_VERSION=11.28.5 ARG PNPM_VERSION=11.28.5
ARG VCS_REF=unknown ARG VCS_REF=unknown
@@ -79,6 +89,11 @@ ENV NODE_ENV=production \
# git/ripgrep/jq/yq/curl/gh are deliberate agent tooling parity: a sandboxed DSH # git/ripgrep/jq/yq/curl/gh are deliberate agent tooling parity: a sandboxed DSH
# instance expects the usual developer CLI. tini reaps signals for a clean stop. # instance expects the usual developer CLI. tini reaps signals for a clean stop.
# bubblewrap is DSH's first-choice command-sandbox runner on Linux. It ships
# unsetuid, so it needs unprivileged user namespaces plus the namespace/mount
# syscalls; a hardened pod (all capabilities dropped, RuntimeDefault seccomp)
# usually cannot create one, which is why DSH then falls back to Landlock and,
# when that is unavailable too, to DSH_PERMISSION_MODE=danger-full-access.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
@@ -90,8 +105,12 @@ RUN apt-get update \
wget \ wget \
tini \ tini \
gh \ gh \
bubblewrap \
python3 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \ && apt-get autoclean -y \
&& install -d -o node -g node -m 0755 /workspace "$DSH_HOME" \ && install -d -o node -g node -m 0755 /workspace "$DSH_HOME" \
&& corepack enable \ && corepack enable \
&& corepack prepare "pnpm@$PNPM_VERSION" --activate \ && corepack prepare "pnpm@$PNPM_VERSION" --activate \
@@ -144,6 +163,6 @@ WORKDIR /workspace
USER node USER node
EXPOSE 3080 EXPOSE 3080
VOLUME ["/home/node/.dsh"] VOLUME ["/home/node/.dsh", "/workspace"]
STOPSIGNAL SIGTERM STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/dsh-entrypoint"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/dsh-entrypoint"]

View File

@@ -27,6 +27,7 @@ help: ## Show available targets
image: ## Build the main DSH Web image (no push) image: ## Build the main DSH Web image (no push)
docker build \ docker build \
--build-arg DSH_VERSION=$(DSH_VERSION) \ --build-arg DSH_VERSION=$(DSH_VERSION) \
--build-arg DSH_COMMIT=$(DSH_COMMIT) \
--build-arg RELEASE_REVISION=$(RELEASE_REVISION) \ --build-arg RELEASE_REVISION=$(RELEASE_REVISION) \
--build-arg PNPM_VERSION=$(PNPM_VERSION) \ --build-arg PNPM_VERSION=$(PNPM_VERSION) \
--build-arg VCS_REF=$(VCS_REF) \ --build-arg VCS_REF=$(VCS_REF) \

View File

@@ -20,7 +20,7 @@ docker run -d --name dsh-web \
-p 3080:3080 \ -p 3080:3080 \
-v dsh-home:/home/node/.dsh \ -v dsh-home:/home/node/.dsh \
-e DEEPSEEK_API_KEY \ -e DEEPSEEK_API_KEY \
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
``` ```
DSH Web is **not usable at a bare `/`**. Without its launch-token cookie it DSH Web is **not usable at a bare `/`**. Without its launch-token cookie it
@@ -45,7 +45,7 @@ docker run -d --name dsh-web -p 18081:3080 \
-e DSH_PUBLIC_URL=http://localhost:18081 \ -e DSH_PUBLIC_URL=http://localhost:18081 \
-v dsh-home:/home/node/.dsh \ -v dsh-home:/home/node/.dsh \
-e DEEPSEEK_API_KEY \ -e DEEPSEEK_API_KEY \
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
# then open http://localhost:18081/?token=... # then open http://localhost:18081/?token=...
``` ```
@@ -94,21 +94,20 @@ ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway
| Tag | Contents | Meaning | | Tag | Contents | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `dsh-v0.2.1-rc.3-r4` | DSH Web, port `3080` | **Current release identity.** Immutable: once published it never moves. | | `dsh-v0.2.1-rc.5-r6` | DSH Web, port `3080` | **Current release identity.** Immutable: once published it never moves. |
| `latest` | DSH Web | Most recent release; same digest as the current identity. | | `latest` | DSH Web | Most recent release; same digest as the current identity. |
| `main` | DSH Web | Development channel rebuilt from every push to `main`. Not a release; not for production. | | `main` | DSH Web | Development channel rebuilt when image-definition files change on `main`. Not a release; not for production. |
| `dsh-v0.2.1-rc.3` | DSH Web | **Legacy tag** published before the release-identity scheme. It is not updated. New deployments use `dsh-v0.2.1-rc.3-r4`. |
The example gateway is published under its nested path with the same release The example gateway is published under its nested path with the same release
identity, e.g. identity, e.g.
`ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4`. `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6`.
Release identities, channels and rollback rules are explained in Release identities, channels and rollback rules are explained in
[docs/versioning.md](docs/versioning.md). Production deployments should pin a [docs/versioning.md](docs/versioning.md). Production deployments should pin a
digest: digest:
```bash ```bash
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest> # image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
``` ```
@@ -132,8 +131,8 @@ Self-contained implementations that consume the image. Index and contract:
| Example | What it demonstrates | | Example | What it demonstrates |
| --- | --- | | --- | --- |
| [`examples/dsh-agents-as-a-service/`](examples/dsh-agents-as-a-service/) | Full multi-tenant platform: SandboxTemplate with per-user PVCs, warm pool, one claim per user, suspend-when-idle, wake-on-return, browser gateway with signup + `HttpOnly` sessions + HTTP/WebSocket proxy, Ingress or Gateway API + GitHub SSO exposure, and opt-in gVisor/NetworkPolicy/provider-key hardening. Start with the [walkthrough](examples/dsh-agents-as-a-service/README.md) and the [architecture](examples/dsh-agents-as-a-service/docs/architecture.md). | | [`examples/dsh-agents-as-a-service/`](examples/dsh-agents-as-a-service/) | Full multi-tenant platform: SandboxTemplate with per-user PVCs, warm pool, one claim per user, suspend-when-idle, wake-on-return, browser gateway with signup + `HttpOnly` sessions + HTTP/WebSocket proxy, Ingress or Gateway API + GitHub SSO exposure, and opt-in gVisor/NetworkPolicy/provider-key hardening and failed-Pod recovery. Start with the [walkthrough](examples/dsh-agents-as-a-service/README.md) and the [architecture](examples/dsh-agents-as-a-service/docs/architecture.md). |
| [`examples/minimal-single-tenant/`](examples/minimal-single-tenant/) | The smallest useful setup: one `SandboxTemplate` and one `SandboxClaim`, reached through `kubectl port-forward` + the launch-token URL. Manifest: [`manifests/sandbox-template.yaml`](examples/minimal-single-tenant/manifests/sandbox-template.yaml). | | [`examples/minimal-single-tenant/`](examples/minimal-single-tenant/) | The smallest useful setup: one `SandboxTemplate` and one `SandboxClaim`, reached through `kubectl port-forward` + the launch-token URL. Manifest: [`manifests/10-sandboxtemplate.yaml`](examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml). |
The gateway is a component of the multi-tenant example and has its own The gateway is a component of the multi-tenant example and has its own
[README](examples/dsh-agents-as-a-service/gateway/README.md) (API, configuration, [README](examples/dsh-agents-as-a-service/gateway/README.md) (API, configuration,
@@ -164,7 +163,7 @@ guide and troubleshooting notes live beside `SKILL.md`.
| [`scripts/`](scripts/) | Repository checks: versions, example layout, manifest YAML, version bump. | | [`scripts/`](scripts/) | Repository checks: versions, example layout, manifest YAML, version bump. |
| [`.dsh/skills/`](.dsh/skills/) | Project-local DSH skills, auto-discovered from the git root. | | [`.dsh/skills/`](.dsh/skills/) | Project-local DSH skills, auto-discovered from the git root. |
| [`.github/`](.github/) | CI, release automation, dependency updates, PR/issue templates. | | [`.github/`](.github/) | CI, release automation, dependency updates, PR/issue templates. |
| [`.env.sample`](.env.sample) | Documented local configuration (copy to `.env`; never commit it). | | [`.env.sample`](.env.sample) | Reference environment names for local runs; nothing reads it automatically. Never commit a real key. |
## Development ## Development
@@ -189,34 +188,15 @@ required checks and releases: [docs/gitflow.md](docs/gitflow.md).
The release identity is the upstream DSH ref plus an optional repository The release identity is the upstream DSH ref plus an optional repository
revision: `DSH_VERSION` and `RELEASE_REVISION` in [versions.env](versions.env). revision: `DSH_VERSION` and `RELEASE_REVISION` in [versions.env](versions.env).
The current identity is **`dsh-v0.2.1-rc.3-r4`** — the first release under the The current identity is **`dsh-v0.2.1-rc.5-r6`** — the first release at this
mirrored-DSH scheme at the same upstream DSH ref as the legacy upstream DSH ref, so it carries no repository revision. Tags are immutable; a
`dsh-v0.2.1-rc.3` tag. Tags are immutable; a broken release is fixed by broken release is fixed by publishing `-r(N+1)`, never by re-tagging.
publishing `-r(N+1)`, never by re-tagging.
`CHANGELOG.md` is generated by [git-cliff](https://git-cliff.org) from `CHANGELOG.md` is generated by [git-cliff](https://git-cliff.org) from
Conventional Commits and is never edited by hand. See Conventional Commits and is never edited by hand. See
[`CHANGELOG.md`](CHANGELOG.md), [docs/versioning.md](docs/versioning.md) and [`CHANGELOG.md`](CHANGELOG.md), [docs/versioning.md](docs/versioning.md) and
[cliff.toml](cliff.toml). [cliff.toml](cliff.toml).
## Migrating from the previous layout
If you are coming from an older checkout, these paths moved. Nothing else changed
semantically; the content was split into the example it belongs to.
| Old path | New path |
| --- | --- |
| `gateway-api/dsh-demo/` (Gateway API + oauth2-proxy / GitHub SSO) | `examples/dsh-agents-as-a-service/manifests/exposure/gateway-api-sso/` (now `70-gateway.yaml` ... `74-tls-secret.yaml.example`) |
| `examples/dsh-agents-as-a-service/60-ingress.yaml` | `examples/dsh-agents-as-a-service/manifests/exposure/ingress/60-ingress.yaml` |
| `examples/dsh-agents-as-a-service/NN-*.yaml` (`00-` ... `50-`) | `examples/dsh-agents-as-a-service/manifests/NN-*.yaml` |
| `examples/dsh-agents-as-a-service/run-test-kind.sh` | `examples/dsh-agents-as-a-service/scripts/e2e-kind.sh` |
| `deploy/k8s/sandbox-template.yaml` | `examples/minimal-single-tenant/manifests/sandbox-template.yaml` |
| `docs/architecture.md` | `examples/dsh-agents-as-a-service/docs/architecture.md` |
The SSO walkthrough that lived in `gateway-api/dsh-demo/README.md` is merged
into the [multi-tenant example README](examples/dsh-agents-as-a-service/README.md)
(step 9) and the [exposure README](examples/dsh-agents-as-a-service/manifests/exposure/README.md).
## Contributing ## Contributing
- [CONTRIBUTING.md](CONTRIBUTING.md) — local setup, PR workflow, what a change needs. - [CONTRIBUTING.md](CONTRIBUTING.md) — local setup, PR workflow, what a change needs.

View File

@@ -21,23 +21,41 @@ disclosure once a fix is available.
from pod logs (pods/log). Anyone with access to those logs can impersonate a from pod logs (pods/log). Anyone with access to those logs can impersonate a
tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as
sensitive. Replacing log scraping with an upstream token API is tracked in sensitive. Replacing log scraping with an upstream token API is tracked in
the example architecture document. the [example architecture document](examples/dsh-agents-as-a-service/docs/architecture.md#upstream-feature-request-remove-log-scraping).
- **Browser sessions are server-side.** The gateway session cookie holds an
opaque session id, not a user name or token; it is not accepted as a bearer
token. Sessions are revoked on logout, user delete, `/recreate` and expiry,
but they live in gateway memory, so a gateway restart logs every browser out
and scaling beyond one replica requires a shared store. Do not treat the
cookie as durable or cross-replica state.
- **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an - **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an
identity header (for example X-Forwarded-User). Enable it only when the identity header (for example X-Forwarded-User). Enable it only when the
gateway Service is reachable exclusively through the authenticating proxy; gateway Service is reachable exclusively through the authenticating proxy
otherwise any client can spoof the header. The SSO exposure variant ships a and its NetworkPolicy; otherwise any client can spoof the header. The SSO
NetworkPolicy that restricts gateway access to oauth2-proxy pods; a custom exposure variant's policy is required, not optional: an exposure without it
exposure needs an equivalent. is demo-only. Run `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
after applying the variant; it checks the Secrets, gateway env, TLS
reference, leftover placeholders and accidental second paths.
- **Insecure-cookie guard.** TLS terminates outside the gateway. Set
COOKIE_SECURE=true for HTTPS and remove ALLOW_INSECURE_COOKIES; the gateway
refuses to start with COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1 is
explicitly set, which exists only for the plain-HTTP local demo. Never expose
that combination outside localhost.
- **Abuse controls are in-process.** AUTH_RATE_LIMIT/AUTH_RATE_WINDOW,
SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and WS_MAX_MESSAGE_BYTES bound
casual abuse and resource exhaustion on the single gateway replica. They
reset on restart and are not an ingress rate limiter, WAF, quota system or
audit log; add those for an internet-facing deployment.
- **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in - **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in
the optional dsh-provider-keys-extra Secret into every tenant sandbox (a the optional dsh-provider-keys-extra Secret into every tenant sandbox (a
warm-pool constraint). Treat tenants as mutually trusting until per-tenant warm-pool constraint). Treat tenants as mutually trusting until per-tenant
credentials or an egress proxy exist. credentials or an egress proxy exist.
- **Optional isolation is opt-in.** gVisor, nodeSelector, the sandbox/exposure - **The base example is demo-only, not hardened.** gVisor, nodeSelector, the
NetworkPolicies and extra provider keys are opt-in. Without a sandbox/exposure NetworkPolicies, the gateway egress policy and extra provider
NetworkPolicy-capable CNI or a gVisor runtime, the base example is not keys are opt-in. Without gVisor and the network policies there is no
tenant-isolated; see examples/dsh-agents-as-a-service/docs/optional-hardening.md. tenant-to-tenant isolation; the sandboxes share the host kernel and can reach
- **TLS is external.** Terminate TLS at an Ingress or Gateway and set cluster-internal endpoints. Production requirements are enumerated in the
COOKIE_SECURE=true. Plain HTTP outside localhost exposes session cookies. [production checklist](examples/dsh-agents-as-a-service/docs/optional-hardening.md#6-production-checklist).
- **No secrets in the repository.** The repository only ships empty Secret - **No secrets in the repository.** The repository only ships empty Secret
manifests and *.yaml.example files. manifests and *.yaml.example files.

View File

@@ -44,6 +44,6 @@ commit_parsers = [
] ]
protect_breaking_commits = false protect_breaking_commits = false
filter_commits = false filter_commits = false
# Release tags are dsh-vX.Y.Z[-rN]; the single legacy pre-scheme tag is v0.0.1. # Release tags are dsh-vX.Y.Z[-rN]; the single pre-scheme tag is v0.0.1.
tag_pattern = "^(v|dsh-v).*$" tag_pattern = "^(v|dsh-v).*$"
sort_commits = "oldest" sort_commits = "oldest"

View File

@@ -2,8 +2,7 @@
Repository-wide documentation lives here. Example-specific material lives with Repository-wide documentation lives here. Example-specific material lives with
its example under [`examples/<name>/docs/`](../examples/) — see the its example under [`examples/<name>/docs/`](../examples/) — see the
[agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md) [agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md).
for the deep-dive that used to live in this directory.
If you are new, read the [root README](../README.md) first: it is image-first and If you are new, read the [root README](../README.md) first: it is image-first and
gets a DSH Web container running before anything else. gets a DSH Web container running before anything else.
@@ -15,7 +14,8 @@ gets a DSH Web container running before anything else.
| [main-image.md](main-image.md) | The primary artifact: build args, runtime environment, entrypoint, probes, volumes/ports/signals, non-root contract, image names, tags and digests. | | [main-image.md](main-image.md) | The primary artifact: build args, runtime environment, entrypoint, probes, volumes/ports/signals, non-root contract, image names, tags and digests. |
| [examples.md](examples.md) | The example contract: required layout, manifest rules, exposure variants, components and image naming, scripts, docs, and how to add a new example. | | [examples.md](examples.md) | The example contract: required layout, manifest rules, exposure variants, components and image naming, scripts, docs, and how to add a new example. |
| [gitflow.md](gitflow.md) | Branches, protected `main`, squash merges, Conventional PR titles, required CI checks, release branches, hotfixes and the one-time GitHub settings checklist. | | [gitflow.md](gitflow.md) | Branches, protected `main`, squash merges, Conventional PR titles, required CI checks, release branches, hotfixes and the one-time GitHub settings checklist. |
| [versioning.md](versioning.md) | Release identity (`DSH ref` + optional `-rN`), why the current identity is `dsh-v0.2.1-rc.3-r4`, channels, compatibility matrix, bumping pins, the release checklist, rollback, git-cliff and digest pinning. | | [versioning.md](versioning.md) | Release identity (`DSH ref` + optional `-rN`), the current identity `dsh-v0.2.1-rc.5-r6`, why identities are immutable, channels, compatibility matrix, bumping pins, the release checklist, rollback, git-cliff and digest pinning. |
| [workflow-security.md](workflow-security.md) | GitHub Actions security posture: least-privilege tokens, pinned actions, untrusted-input handling, download integrity checks, artifact hygiene, the latest audit record and residual repository-setting recommendations. |
## Project skills ## Project skills
@@ -34,6 +34,8 @@ DSH discovers project skills from `<git root>/.dsh/skills/<name>/SKILL.md`
| [dsh-agents-as-a-service README](../examples/dsh-agents-as-a-service/README.md) | The multi-tenant platform walkthrough: warm pool, claims, suspend/resume, gateway, exposure and troubleshooting. | | [dsh-agents-as-a-service README](../examples/dsh-agents-as-a-service/README.md) | The multi-tenant platform walkthrough: warm pool, claims, suspend/resume, gateway, exposure and troubleshooting. |
| [dsh-agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md) | Container/sequence diagrams, ADRs, security and RBAC, failure modes and the evolution path. | | [dsh-agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md) | Container/sequence diagrams, ADRs, security and RBAC, failure modes and the evolution path. |
| [gateway README](../examples/dsh-agents-as-a-service/gateway/README.md) | The gateway's HTTP/WebSocket API, configuration, auth (including SSO), and limitations. | | [gateway README](../examples/dsh-agents-as-a-service/gateway/README.md) | The gateway's HTTP/WebSocket API, configuration, auth (including SSO), and limitations. |
| [optional hardening](../examples/dsh-agents-as-a-service/docs/optional-hardening.md) | Opt-in gVisor, nodeSelector, NetworkPolicy, provider keys and failed-Pod recovery. |
| [exposure variants](../examples/dsh-agents-as-a-service/manifests/exposure/README.md) | The mutually exclusive Ingress and Gateway API + SSO exposure paths. |
## Project policies ## Project policies

View File

@@ -41,6 +41,7 @@ examples/<name>/
example directory; example directory;
- an example has no `README.md` or no `manifests/` directory; - an example has no `README.md` or no `manifests/` directory;
- `manifests/` contains no top-level `*.yaml` files; - `manifests/` contains no top-level `*.yaml` files;
- a top-level `manifests/*.yaml` is not named `NN-*.yaml`;
- a `*.yaml` file sits at the example root instead of under `manifests/`; - a `*.yaml` file sits at the example root instead of under `manifests/`;
- `manifests/exposure/` exists without a `manifests/exposure/README.md` describing - `manifests/exposure/` exists without a `manifests/exposure/README.md` describing
the variants; the variants;

View File

@@ -30,7 +30,7 @@ branch; the PR is squash-merged, so individual branch commits are never part of
`docs(scope): ...`, `build: ...`, `ci: ...`, `refactor: ...`, `test: ...`, `docs(scope): ...`, `build: ...`, `ci: ...`, `refactor: ...`, `test: ...`,
`chore: ...`. Breaking changes use `feat(scope)!: ...` or a `chore: ...`. Breaking changes use `feat(scope)!: ...` or a
`BREAKING CHANGE:` footer. `BREAKING CHANGE:` footer.
3. **CI must be green.** All required checks below must pass before merge. 3. **CI must be green.** All applicable checks below must pass before merge.
4. **Squash merge only.** `main` stays linear and protected; merged PRs are one 4. **Squash merge only.** `main` stays linear and protected; merged PRs are one
commit each. commit each.
5. **No direct pushes to `main`** or to a release branch. Both are protected. 5. **No direct pushes to `main`** or to a release branch. Both are protected.
@@ -40,15 +40,33 @@ branch; the PR is squash-merged, so individual branch commits are never part of
## Required checks ## Required checks
| Check | Workflow / job | What it verifies | | Check | Workflow / job | Required on every PR? | What it verifies |
| --- | --- | --- | | --- | --- | --- | --- |
| `lint` | `ci.yaml` | `scripts/check-versions.sh` (version pins and markdown tokens), ruff on the gateway, shellcheck on shell scripts, yamllint/actionlint when available. | | `lint` | `ci.yaml` | yes | `scripts/check-versions.sh` (version pins and markdown tokens), `scripts/check-examples.sh` (example layout and numbered manifests), shellcheck, actionlint, yamllint, hadolint and documentation links. |
| `python` | `ci.yaml` | Gateway unit tests (`pytest`). | | `python` | `ci-python.yaml` | no (path-filtered) | Gateway ruff check/format and unit tests (`pytest` with coverage). Runs only when the gateway or this workflow changes. |
| `manifests` | `ci.yaml` | `scripts/check-examples.sh`, `scripts/check-manifests.py` and a server-side dry-run of every example manifest against the agent-sandbox CRDs. | | `manifests` | `ci-manifests.yaml` | no (path-filtered) | `scripts/check-manifests.py` (manifest shape) and a server-side dry-run of every example manifest against the agent-sandbox CRDs. Runs when manifest files, `scripts/check-manifests.py` or `versions.env` change. |
| `gateway-image` | `ci.yaml` | Builds the gateway image and smoke-tests `/healthz` without a cluster. | | `pr-title` | `pr-title.yaml` | yes | Conventional Commit PR title. |
| `pr-title` | `pr-title.yaml` | Conventional Commit PR title. | | `Analyze (python)`, `Analyze (actions)` | `codeql.yml` | yes (both jobs) | CodeQL static analysis for Python and GitHub Actions. |
| `CodeQL` | `codeql.yml` | Static analysis for Python and GitHub Actions. | | `dependency-review` | `ci.yaml` | yes | Dependency changes introduced by the PR (job `dependency-review`; runs on pull requests only). |
| `dependency-review` | `ci.yaml` | Dependency changes introduced by the PR (job `dependency-review`; runs on pull requests only). |
`python`, `manifests` and `build-amd64` use native `on.pull_request.paths`
filtering, so PRs that do not touch the gateway, the manifests or the image
definition skip them. The gateway image is built and published by
`build-gateway.yaml` on merge; `/healthz` is covered by the gateway unit tests and
the Dockerfile is linted by `lint` (hadolint).
`ci-image.yaml` (`build-amd64`) is the PR-side check for the main image: when
`Dockerfile`, `.dockerignore` or `versions.env` change it builds `linux/amd64`
with the pins from `versions.env` — no registry login, no push — and smoke-tests
the result (`--version`, non-root UID, `/workspace` + `DSH_HOME`, the
`bubblewrap` runner, the version label, and `/favicon.svg` served from a running
container). The published multi-arch build still happens only in
`build-main.yaml` on merge.
Because a skipped path-filtered check is not a passing check, require only the
always-on set: `lint`, `pr-title`, both CodeQL `Analyze` jobs and
`dependency-review`. Keep `python`, `manifests` and `build-amd64` as on-demand
checks that skip when unrelated to the change.
The image build workflows (`_build-image.yaml`, `build-main.yaml`, The image build workflows (`_build-image.yaml`, `build-main.yaml`,
`build-gateway.yaml`) and `e2e-kind.yaml` are **not** required on every PR: the `build-gateway.yaml`) and `e2e-kind.yaml` are **not** required on every PR: the
@@ -90,7 +108,8 @@ Rollback is also a re-release, never a retag: see
## Tag protection ## Tag protection
All release tags match `dsh-*` and are protected: Release tags follow `dsh-*` and are protected (the pre-scheme `v0.0.1` tag is
historical and unmaintained):
- only maintainers (the release workflow) may create them; - only maintainers (the release workflow) may create them;
- they cannot be deleted or force-updated; - they cannot be deleted or force-updated;
@@ -104,9 +123,10 @@ respectively.
Configure these on the repository before relying on the workflow: Configure these on the repository before relying on the workflow:
- [ ] **Branches → main**: require a pull request; require the checks `lint`, - [ ] **Branches → main**: require a pull request; require the always-on checks
`python`, `manifests`, `gateway-image`, `pr-title`, `CodeQL`, `lint`, `pr-title`, both CodeQL `Analyze` jobs and `dependency-review`
`dependency-review`; require branches to be up to date; require conversation (`python`, `manifests` and `build-amd64` are path-filtered and must not be
required); require branches to be up to date; require conversation
resolution; restrict direct pushes; allow only squash merges and enable resolution; restrict direct pushes; allow only squash merges and enable
"Automatically delete head branches". "Automatically delete head branches".
- [ ] **Branches → release/***: same protections, plus allow the release workflow - [ ] **Branches → release/***: same protections, plus allow the release workflow

View File

@@ -11,14 +11,19 @@ The image is image-first: it is the product, and the examples under
## What the build does ## What the build does
The Dockerfile is a two-stage build: The Dockerfile is a two-stage build. Both stages use the `node` images pinned by
digest in [versions.env](../versions.env): a mutable tag alone is not trusted, and
`./scripts/check-versions.sh` rejects an image reference without a `@sha256:` digest.
1. **Build stage** (`node:24-trixie`) clones DSH at the pinned git ref, installs 1. **Build stage** (`NODE_BUILD_IMAGE`) clones DSH at the pinned git ref, verifies
that the clone's `HEAD` is exactly `DSH_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/dsh` dependencies with pnpm, runs the production build, packages the `@deepseek-ai/dsh`
CLI with `pnpm pack`, and installs its production dependencies with npm. CLI with `pnpm pack`, and installs its production dependencies with npm.
2. **Runtime stage** (`node:24-trixie-slim`) copies `/opt/dsh` to `/app`, adds the 2. **Runtime stage** (`NODE_RUNTIME_IMAGE`) copies `/opt/dsh` to `/app`, adds the
CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`, CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`,
`gh`, `tini`, `uv`), and starts `dsh web` through a small entrypoint. `gh`, `tini`, `uv`), installs `bubblewrap` as DSH's preferred command-sandbox
runner, and starts `dsh web` through a small entrypoint.
## Build it ## Build it
@@ -27,15 +32,19 @@ make image # canonical: builds the DSH Web image, no push
``` ```
`make image` reads [versions.env](../versions.env) and passes the pins as build args. `make image` reads [versions.env](../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`](../.github/workflows/ci-image.yaml)
workflow — no registry login, no push — followed by a container smoke test.
To reproduce the equivalent by hand: To reproduce the equivalent by hand:
```bash ```bash
docker build \ docker build \
--build-arg DSH_VERSION=dsh-v0.2.1-rc.3 \ --build-arg DSH_VERSION=dsh-v0.2.1-rc.5-r6 \
--build-arg RELEASE_REVISION=r4 \ --build-arg DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1 \
--build-arg RELEASE_REVISION= \
--build-arg PNPM_VERSION=11.28.5 \ --build-arg PNPM_VERSION=11.28.5 \
--build-arg VCS_REF="$(git rev-parse --short HEAD)" \ --build-arg VCS_REF="$(git rev-parse --short HEAD)" \
-t ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 . -t ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6 .
``` ```
### Build args ### Build args
@@ -43,12 +52,18 @@ docker build \
| Arg | Default / source | Purpose | | Arg | Default / source | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. | | `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. |
| `DSH_VERSION` | `DSH_VERSION` in [versions.env](../versions.env) | Upstream DSH git tag/ref to package; recorded as `org.opencontainers.image.version`. | | `DSH_VERSION` | `DSH_VERSION` in [versions.env](../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. |
| `RELEASE_REVISION` | `RELEASE_REVISION` in [versions.env](../versions.env) | Repository revision suffix (`rN`); the release identity is `DSH_VERSION-RELEASE_REVISION`, recorded in the `io.sredevopsorg.release.revision` label. See [versioning](versioning.md). | | `DSH_COMMIT` | `DSH_COMMIT` in [versions.env](../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](../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](versioning.md). |
| `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../versions.env) | pnpm version activated with corepack in both stages. | | `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../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. | | `VCS_REF` | `git rev-parse --short HEAD` (via `make image`) | Source revision recorded for traceability. |
| `NODE_BUILD_IMAGE` | `docker.io/node:24-trixie` | Build-stage base image. | | `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` | Runtime-stage base image. | | `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](../versions.env); environments that
mirror the image should also mirror the `node` digest they build from.
Never hard-code a version outside [versions.env](../versions.env): Never hard-code a version outside [versions.env](../versions.env):
`./scripts/check-versions.sh` (and `make check`) fails when a pin drifts. `./scripts/check-versions.sh` (and `make check`) fails when a pin drifts.
@@ -76,10 +91,40 @@ The entrypoint is `tini -- /usr/local/bin/dsh-entrypoint`:
| `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`. | | `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. | | `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. | | `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 The image also sets `NODE_ENV=production`, `HOME=/home/node`, and a `PATH` that
includes `/app/node_modules/.bin`. 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](../examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml),
[agents as a service](../examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml))
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 ### Provider credentials
DSH resolves provider API keys from the environment when no stored credential DSH resolves provider API keys from the environment when no stored credential
@@ -108,7 +153,7 @@ procedure.
| --- | --- | --- | | --- | --- | --- |
| Exposed port | `3080/tcp` | `EXPOSE 3080`; one port serves the UI, assets, API, SSE and WebSocket traffic. | | 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). | | 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. | | 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. | | 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. | | User | `node`, UID/GID `1000` | The process is non-root. A PVC mounted at `DSH_HOME` must be writable by UID 1000. |
@@ -131,8 +176,8 @@ livenessProbe:
periodSeconds: 15 periodSeconds: 15
``` ```
The shipped templates use exactly this shape; see The shipped templates use the same path and startup budget; see
[`examples/minimal-single-tenant/manifests/sandbox-template.yaml`](../examples/minimal-single-tenant/manifests/sandbox-template.yaml). [`examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml`](../examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml).
A `startupProbe` is important because plugin activation (or the first start of a A `startupProbe` is important because plugin activation (or the first start of a
large sandbox) can exceed a default liveness budget. large sandbox) can exceed a default liveness budget.
@@ -164,17 +209,16 @@ GitHub Container Registry:
| Tag | Meaning | | Tag | Meaning |
| --- | --- | | --- | --- |
| `dsh-v0.2.1-rc.3-r4` | Current release identity. **Immutable**: once published it never moves. | | `dsh-v0.2.1-rc.5-r6` | Current release identity. **Immutable**: once published it never moves. |
| `latest` | Most recent release; points at the same digest as the current identity. | | `latest` | Most recent release; points at the same digest as the current identity. |
| `main` | Development channel rebuilt from every push to `main`; not a release, not for production. | | `main` | Development channel rebuilt when image-definition files change on `main`; not a release, not for production. |
| `dsh-v0.2.1-rc.3` | Legacy tag published before the release-identity scheme existed. Do not use it for new deployments; see the migration table in the [root README](../README.md). |
Release identities, channels and rollback rules are defined in Release identities, channels and rollback rules are defined in
[versioning](versioning.md). Tags are immutable, so production deployments should [versioning](versioning.md). Tags are immutable, so production deployments should
pin a digest: pin a digest:
```bash ```bash
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
# ... pin the digest in your deployment manifests: # ... pin the digest in your deployment manifests:
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest> # image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
``` ```
@@ -182,9 +226,10 @@ docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbo
## Verifying an image ## Verifying an image
```bash ```bash
docker inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 \ docker inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6 \
--format '{{json .Config.Labels}}' --format '{{json .Config.Labels}}'
# org.opencontainers.image.title / .version / .source # 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 Release builds also publish SBOM and provenance attestations from the release

View File

@@ -15,48 +15,40 @@ release identity = DSH_VERSION + optional "-" + RELEASE_REVISION
| Piece | Meaning | | Piece | Meaning |
| --- | --- | | --- | --- |
| `DSH_VERSION` | The upstream DeepSeek Harness git ref being packaged, e.g. `dsh-v0.2.1-rc.3`. It mirrors upstream's own tag, which is what "mirrored-DSH scheme" means. | | `DSH_VERSION` | The upstream DeepSeek Harness git ref being packaged, e.g. `dsh-v0.2.1-rc.5-r6`. It mirrors upstream's own tag. |
| `RELEASE_REVISION` | An optional repository revision at that DSH ref: empty for the first release, `rN` (`N >= 2`) for a repository-only re-release. | | `RELEASE_REVISION` | An optional repository revision at that DSH ref: empty for the first release, `rN` (`N >= 2`) for a repository-only re-release. |
Examples: Examples:
- `DSH_VERSION=dsh-v0.2.1-rc.3`, `RELEASE_REVISION=` → identity `dsh-v0.2.1-rc.3` - `DSH_VERSION=dsh-v0.2.1-rc.5-r6`, `RELEASE_REVISION=` → identity `dsh-v0.2.1-rc.5-r6`
(first release at that upstream ref). (first release at that upstream ref).
- `DSH_VERSION=dsh-v0.2.1-rc.3`, `RELEASE_REVISION=r4` → identity - `DSH_VERSION=<ref>`, `RELEASE_REVISION=rN` → identity `<ref>-rN` (for example
`dsh-v0.2.1-rc.3-r4` (documentation, CI or gateway changes on top of the same documentation, CI or gateway changes on top of an already-released ref; no DSH
upstream DSH; no DSH upgrade). upgrade). Use `r2` for the first re-release: revision 1 is the plain tag.
A DSH upgrade produces a new base identity (for example the next upstream ref A DSH upgrade produces a new base identity (for example the next upstream ref
with an empty revision); a repository-only change at the same DSH ref increments with an empty revision); a repository-only change at the same DSH ref increments
`rN`. `rN`.
## Why the current identity is `dsh-v0.2.1-rc.3-r4` ## Why release identities are immutable
The image tag `dsh-v0.2.1-rc.3` was already published to GHCR for an earlier **Container tags are immutable** in this project: re-pushing a tag would silently
repository state. **Container tags are immutable** in this project: re-pushing a change what an existing deployment runs. A fix therefore publishes a new identity;
tag would silently change what an existing deployment runs. The mirrored-DSH see the rules above.
scheme therefore starts its first identity at `-r2`: the plain tag is
conceptually revision 1 and is taken, so the first publishable repository
revision at the same upstream ref is `r2`.
The identity is stored in [versions.env](../versions.env) and consumed everywhere: The current identity is `dsh-v0.2.1-rc.5-r6`, the first release at this upstream
the Dockerfile label, the example manifests, the Makefile and the release ref, so it carries no revision suffix. The identity is stored in
workflow. The legacy `dsh-v0.2.1-rc.3` tag remains published but is not updated; [versions.env](../versions.env) and consumed everywhere: the Dockerfile labels
new deployments use `dsh-v0.2.1-rc.3-r4`. (version and revision), the example manifests, the Makefile and the release
workflow.
The gateway image moved from the repository-level `:gateway` tag to a nested
package (`<repository>/dsh-agents-as-a-service/gateway`). The release workflow
also republishes the legacy `<repository>:gateway` tag as a deprecated
compatibility alias; it may be removed in a later release, so deployments
should pin the nested package.
## Channels ## Channels
| Channel | Image tag | Meaning | | Channel | Image tag | Meaning |
| --- | --- | --- | | --- | --- | --- |
| Release | `dsh-v0.2.1-rc.3-r4` | The current immutable release identity. What production should pin (preferably by digest). | | Release | `dsh-v0.2.1-rc.5-r6` | The current immutable release identity. What production should pin (preferably by digest). |
| `latest` | `latest` | Alias for the most recent release; moves only when a new release is published. | | `latest` | `latest` | Alias for the most recent release; moves only when a new release is published. |
| `main` | `main` | Development channel, rebuilt from every push to `main`. Never treat it as a release; it exists so `main` can be tested. | | `main` | `main` | Development channel, rebuilt when image or gateway sources change on `main`. Never treat it as a release; it exists so `main` can be tested. |
The same channels exist for every published package, including the nested The same channels exist for every published package, including the nested
example component image. example component image.
@@ -68,15 +60,28 @@ Values from [versions.env](../versions.env) at the current identity:
| Component | Pin | Source | | Component | Pin | Source |
| --- | --- | --- | | --- | --- | --- |
| Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) | | Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) |
| DSH ref | `dsh-v0.2.1-rc.3` | `DSH_VERSION` | | DSH ref | `dsh-v0.2.1-rc.5-r6` | `DSH_VERSION` |
| Repository revision | `r4` | `RELEASE_REVISION` | | DSH commit | `b830ec950faba0a46dda564be3e2d854afed94f1` | `DSH_COMMIT`; the main image build fails unless the clone's `HEAD` matches |
| Repository revision | `(empty)` | `RELEASE_REVISION` |
| pnpm | `11.28.5` | `PNPM_VERSION` | | pnpm | `11.28.5` | `PNPM_VERSION` |
| agent-sandbox (CRDs) | `v1.0.7` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run | | Node build image | `docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19` | `NODE_BUILD_IMAGE`, digest-pinned |
| Node runtime image | `docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446` | `NODE_RUNTIME_IMAGE`, digest-pinned |
| Gateway Python image | `docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159` | `GATEWAY_PYTHON_IMAGE`, digest-pinned |
| agent-sandbox (CRDs) | `v1.0.9` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run |
| Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant | | Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant |
| oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5` | `OAUTH2_PROXY_IMAGE` | | oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916` | `OAUTH2_PROXY_IMAGE`, digest-pinned and used by the SSO exposure variant |
| Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` | | Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` |
| Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` | | Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` |
Every container base image is referenced as `name:tag@sha256:<index digest>`, not
by tag alone: `check-versions.sh` rejects a pin without a digest, so a re-pushed
tag cannot change what we build from. The example gateway's Python dependencies
are fully hash-pinned in
[requirements.txt](../examples/dsh-agents-as-a-service/gateway/requirements.txt)
and
[requirements-dev.txt](../examples/dsh-agents-as-a-service/gateway/requirements-dev.txt)
and installed with `pip install --require-hashes`.
Example component images are always nested below the main package: Example component images are always nested below the main package:
`<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated `<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated
top-level package. top-level package.
@@ -94,8 +99,15 @@ file to derive from it. Never edit the Dockerfile, manifests, Makefile or
workflows with a literal version. workflows with a literal version.
- New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`. - New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`.
- Repository-only change at the same DSH ref: keep `DSH_VERSION`, set `set-version` resolves the new ref's commit with `git ls-remote` (peeled `^{}`
`RELEASE_REVISION=r(N+1)`. commit first, tag ref as fallback) and rewrites `DSH_COMMIT` in `versions.env`,
the Dockerfile ARG default and `.env.sample`; the build then verifies it.
- Repository-only change at the same DSH ref: keep `DSH_VERSION` and `DSH_COMMIT`,
set `RELEASE_REVISION=r(N+1)`.
- Base images (Node and Python) are bumped by hand: change the tag *and* the
`@sha256:` digest in `versions.env` together, then run `make check` and a build.
There is deliberately no automated digest refresh, because a silent base-image
change is exactly what digest pinning exists to prevent.
## Release checklist ## Release checklist
@@ -106,14 +118,16 @@ workflows with a literal version.
the tag does not already exist, and previews the generated notes. the tag does not already exist, and previews the generated notes.
5. Create and push the annotated `dsh-*` release tag (or dispatch 5. Create and push the annotated `dsh-*` release tag (or dispatch
`release.yaml` with an existing tag). The workflow validates that the tag `release.yaml` with an existing tag). The workflow validates that the tag
matches [versions.env](../versions.env). matches [versions.env](../versions.env) and that the upstream ref still
resolves to `DSH_COMMIT` before building anything.
6. The workflow builds and pushes multi-arch images for the main image and the 6. The workflow builds and pushes multi-arch images for the main image and the
example components (the identity tag plus `latest`), creates the GitHub example components (the identity tag plus `latest`), creates the GitHub
Release from the git-cliff notes, records the published digests, and opens a Release from the git-cliff notes, and opens a `chore(changelog)` PR that
`chore(changelog)` PR that refreshes `CHANGELOG.md` on `main`. refreshes `CHANGELOG.md` on `main`. The publish log shows each image manifest
and digest.
7. Merge the changelog PR. Verify: 7. Merge the changelog PR. Verify:
`docker buildx imagetools inspect <image>:<identity>` shows both platforms, `docker buildx imagetools inspect <image>:<identity>` shows both platforms;
and the GitHub Release references the tag and the digest. the GitHub Release references the tag, and the workflow log shows the digest.
8. Announce the identity and the digest; deployments pin the digest. 8. Announce the identity and the digest; deployments pin the digest.
## Rollback ## Rollback
@@ -149,11 +163,20 @@ make changelog # regenerate CHANGELOG.md locally
## Digest pinning ## Digest pinning
Release identities are mutable only in theory; deployments should still pin the Supply-chain inputs are pinned to immutable content, not to mutable tags:
resolved digest for reproducibility and supply-chain safety:
- **Build inputs**: every container base image is referenced by multi-arch index
digest in [versions.env](../versions.env), and the example gateway's Python
dependencies are hash-pinned (`--require-hashes`).
- **Upstream source**: the Dockerfile clones `DSH_VERSION` and fails unless the
clone's `HEAD` equals `DSH_COMMIT`, so a moved upstream tag cannot change what
a rebuild packages.
Release identities are immutable in theory; deployments should still pin the
_resolved_ digest for reproducibility and supply-chain safety:
```bash ```bash
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
# then, in the deployment: # then, in the deployment:
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest> # image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
``` ```

69
docs/workflow-security.md Normal file
View File

@@ -0,0 +1,69 @@
# Workflow security (GitHub Actions)
The CI/CD surface lives entirely in [`.github/workflows/`](../.github/workflows/)
and runs on GitHub-hosted runners. This page describes the controls those
workflows enforce and records the most recent audit. They complement the threat
notes in [SECURITY.md](../SECURITY.md) and the release procedure in
[versioning.md](versioning.md).
## Controls
| Control | Where | What it does |
| --- | --- | --- |
| Least-privilege tokens | Every workflow | A top-level `permissions:` block defaults to read-only; `packages: write`, `contents: write`, `id-token: write` and `attestations: write` are granted only to the jobs that publish images, attestations or releases. |
| Pinned actions | Every `uses:` | Third-party actions and reusable workflows are pinned to a full 40-character commit SHA (the version is kept in the trailing comment). Dependabot updates the pins weekly. The only unpinned reference is the in-repository `./.github/workflows/_build-image.yaml`. |
| No privileged untrusted triggers | Trigger blocks | No workflow uses `pull_request_target` or `workflow_run`. PR-triggered workflows run untrusted code with a read-only token and no secrets. |
| Untrusted input is data | `release.yaml` | The release tag (from a tag push or `workflow_dispatch` input) is passed through `env:` and read as a shell variable; it is never interpolated into a `run:` script, so metacharacters in a tag name cannot execute. |
| Checkout credential hygiene | Checkout steps | `persist-credentials: false` is set wherever the job does not need to push. The changelog job keeps credentials because it pushes a branch and opens a PR with `GITHUB_TOKEN`. |
| Download integrity | `ci.yaml`, `release.yaml` | actionlint, hadolint and git-cliff are downloaded with `curl --proto '=https'` and verified against a pinned SHA-256 with `sha256sum -c` before execution. |
| Cluster manifest integrity | `ci-manifests.yaml`, `e2e-kind.yaml` | The agent-sandbox and Gateway API release manifests are downloaded and checked against the `*_MANIFEST_SHA256` pins in [versions.env](../versions.env) before `kubectl apply`. |
| Log and artifact hygiene | `ci-image.yaml`, `e2e-kind.yaml` | Pod logs are truncated and token material is redacted before logs are printed or diagnostic files are uploaded as artifacts. |
| Supply-chain monitoring | Repository settings | Dependabot (GitHub Actions, pip, Docker), CodeQL advanced setup and the dependency-review action run on every applicable change. CODEOWNERS covers the whole repository, including `.github/workflows/`. |
## Maintaining the pins
- The tool versions and their SHA-256 digests live together in the install step
(`ACTIONLINT_SHA256`, `HADOLINT_SHA256`, `GIT_CLIFF_SHA256`); update both
values when bumping a version.
- Control-plane versions are in `versions.env`; update
`AGENT_SANDBOX_MANIFEST_SHA256` or `GATEWAY_API_MANIFEST_SHA256` together with
the corresponding version, otherwise CI fails the digest check by design.
- GitHub Actions pins are updated by Dependabot; never replace a SHA with a tag.
## Audit record (2026-10-30)
Scope: all 11 workflows under `.github/workflows/`, plus `.github/CODEOWNERS`
and `.github/dependabot.yml`. Findings are grouped by the categories in
GitHub's [Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use).
| # | Category | File | Severity | Finding | Status |
| --- | --- | --- | --- | --- | --- |
| 1 | Script injection | `release.yaml` | High | Tag name / dispatch input interpolated into a privileged `run:` block | Fixed: passed through `env` |
| 2 | Token permissions | `release.yaml` | Medium | `meta` and `tests` inherited workflow-level write scopes | Fixed: workflow default is `contents: read` |
| 3 | Token permissions | `build-main.yaml`, `build-gateway.yaml` | Low | `meta` inherited `packages`/`id-token`/`attestations` write | Fixed: workflow default is `contents: read` |
| 4 | Checkout | `_build-image.yaml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` |
| 5 | Checkout | `codeql.yml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` |
| 6 | Supply chain | `ci.yaml` | Medium | actionlint and hadolint were executed without a checksum | Fixed: pinned SHA-256 verified before install |
| 7 | Supply chain | `release.yaml` | Medium | git-cliff was executed without a checksum in the release job | Fixed: pinned SHA-256 verified before install |
| 8 | Supply chain | `ci-manifests.yaml`, `e2e-kind.yaml` | Medium | Upstream CRD/controller manifests applied without integrity checks | Fixed: pinned SHA-256 in `versions.env` |
| 9 | Secret hygiene | `e2e-kind.yaml` | Low | Raw gateway logs uploaded in a 7-day artifact | Fixed: `token=` values redacted |
| 10 | Repository controls | Repository settings | Low | No environment approval gate on publishing; single CODEOWNER | Recommendation: see below |
Checked and clean: no `pull_request_target`/`workflow_run`, no self-hosted
runners, all third-party actions pinned, PR workflows read-only, no secrets in
workflow files, no secret interpolation into scripts.
## Residual recommendations (repository settings)
These cannot be enforced from workflow files alone:
- Add a protected `release` environment with required reviewers and attach it to
the `build-main`, `build-gateway` and `publish-release` jobs. This requires the
environment to exist in repository settings first, so it is intentionally not
wired into the workflow yet.
- Keep "Allow GitHub Actions to create and approve pull requests" limited to
creating PRs (the changelog job needs create, not approve).
- Require review from Code Owners for `.github/workflows/` and add a second
owner so workflow changes are never single-person.
- Optionally run OpenSSF Scorecard on a schedule for continuous checks on these
controls.

View File

@@ -11,7 +11,7 @@ reference material, not libraries.
| [`examples/minimal-single-tenant/`](minimal-single-tenant/) | The smallest useful setup: a single `SandboxTemplate` and one `SandboxClaim`, no gateway — reach DSH Web with a launch-token URL through `kubectl port-forward`. | agent-sandbox with extensions. | | [`examples/minimal-single-tenant/`](minimal-single-tenant/) | The smallest useful setup: a single `SandboxTemplate` and one `SandboxClaim`, no gateway — reach DSH Web with a launch-token URL through `kubectl port-forward`. | agent-sandbox with extensions. |
The multi-tenant example also documents opt-in gVisor, `nodeSelector`, The multi-tenant example also documents opt-in gVisor, `nodeSelector`,
NetworkPolicy and extra provider-key hardening in NetworkPolicy, extra provider-key hardening and failed-Pod recovery in
[optional hardening](dsh-agents-as-a-service/docs/optional-hardening.md). [optional hardening](dsh-agents-as-a-service/docs/optional-hardening.md).
## The example contract (short version) ## The example contract (short version)
@@ -19,37 +19,26 @@ NetworkPolicy and extra provider-key hardening in
Full rules: [docs/examples.md](../docs/examples.md). `scripts/check-examples.sh` Full rules: [docs/examples.md](../docs/examples.md). `scripts/check-examples.sh`
enforces the layout, and `make check` runs it. enforces the layout, and `make check` runs it.
``` - `README.md` is the walkthrough; `manifests/NN-*.yaml` is applied with plain
examples/<name>/ kubectl and read in order.
+-- README.md # walkthrough: prerequisites, steps, troubleshooting, cleanup - Optional `manifests/optional/<feature>/` and `manifests/exposure/<variant>/`
+-- manifests/ # NN-*.yaml, applied with plain kubectl each ship a README; exposure variants are mutually exclusive.
| +-- optional/<feature>/ # optional opt-in manifests... - Optional `scripts/` must be executable, idempotent and self-cleaning; `docs/`
| +-- optional/README.md # ...required whenever optional/ exists holds deeper material.
| +-- exposure/<variant>/ # optional per-variant manifests... - Components with a Dockerfile need a README and tests; images are published
| +-- exposure/README.md # ...required whenever exposure/ exists nested below the main image (`<registry>/<owner>/<repo>/<example>/<component>`).
+-- scripts/ # optional executable, idempotent, self-cleaning *.sh - Secrets are `*.yaml.example` with placeholders; every example is listed above
+-- docs/ # optional architecture/ADR material with its literal `examples/<name>/` path.
+-- <component>/ # optional component with Dockerfile + README + tests
```
- Secrets never ship populated: use `*.yaml.example` files with placeholders.
- Manifests are numbered (`00-`, `10-`, ...) and read as an ordered walkthrough.
- Component images are published nested below the main image:
`<registry>/<owner>/<repo>/<example>/<component>`.
- Every example must be listed above with its literal `examples/<name>/` path.
- Scripts under `scripts/` must be executable, idempotent and clean up after
themselves.
## Running an example ## Running an example
```bash Each example README has the exact prerequisites, commands, optional hardening,
# e.g. the multi-tenant platform example exposure options and teardown. The multi-tenant example also has a full kind
kubectl apply -f examples/dsh-agents-as-a-service/manifests/ walkthrough that cleans up after itself:
# or run the whole walkthrough (kind + agent-sandbox required) and let it clean up ```bash
examples/dsh-agents-as-a-service/scripts/e2e-kind.sh examples/dsh-agents-as-a-service/scripts/e2e-kind.sh
``` ```
`kubectl apply -f manifests/` is non-recursive: `optional/` and `exposure/` `kubectl apply -f manifests/` is non-recursive: `optional/` and `exposure/`
subdirectories are applied explicitly. Each example README has the exact subdirectories are applied explicitly.
prerequisites, optional hardening, exposure options and teardown.

View File

@@ -6,16 +6,19 @@ platform pattern into pure agent-sandbox resources + kubectl, running **DeepSeek
Harness Web (DSH)** instead of Hermes. Every user of such a platform gets a Harness Web (DSH)** instead of Hermes. Every user of such a platform gets a
personal, long-lived DSH Web instance that is **provisioned in ~2 seconds from a personal, long-lived DSH Web instance that is **provisioned in ~2 seconds from a
warm pool, suspended when idle to save cost, and woken transparently when the warm pool, suspended when idle to save cost, and woken transparently when the
user returns** — with all state (conversations, sessions, config) surviving on a user returns** — with all state (conversations, sessions, config, and the agent
per-user PVC at `/home/node/.dsh`. workspace) surviving on the per-user PVC at `/home/node/.dsh` + `/workspace`.
Suspend-when-idle plus the warm pool keep cost proportional to use: a return
after absence pays a short cold-start pause, not an always-on instance.
It ships in three layers: It ships in three layers:
1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every 1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every
mechanism is visible. mechanism is visible.
2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that 2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that
makes the platform browser-usable — a signup page/API, `HttpOnly` cookie makes the platform browser-usable — a signup page/API, revocable server-side
sessions, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently **opaque-cookie sessions**, auth rate limiting and an optional signup invite
code, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently
**wakes suspended instances on connect**, and an idle sweeper that suspends **wakes suspended instances on connect**, and an idle sweeper that suspends
them again. The gateway performs the DSH launch-token handshake server-side, them again. The gateway performs the DSH launch-token handshake server-side,
so the browser never sees the token. so the browser never sees the token.
@@ -46,7 +49,7 @@ What it demonstrates, feature by feature:
| agent-sandbox feature | Role in the platform | | agent-sandbox feature | Role in the platform |
| --- | --- | | --- | --- |
| `SandboxTemplate` with `volumeClaimTemplates` | Identical DSH pods; per-user state on a PVC at `/home/node/.dsh` | | `SandboxTemplate` with `volumeClaimTemplates` | Identical DSH pods; per-user state at `/home/node/.dsh` and the agent workspace at `/workspace`, on one PVC (the workspace is a `subPath` mount, so DSH state stays private) |
| `envVarsInjectionPolicy` / `volumeClaimTemplatesPolicy: Disallowed` | Claims that would bypass the warm pool are **rejected loudly**, not cold-started silently | | `envVarsInjectionPolicy` / `volumeClaimTemplatesPolicy: Disallowed` | Claims that would bypass the warm pool are **rejected loudly**, not cold-started silently |
| `service: true` | Stable per-sandbox DNS (`status.serviceFQDN`) for the gateway to route on | | `service: true` | Stable per-sandbox DNS (`status.serviceFQDN`) for the gateway to route on |
| `SandboxWarmPool` | Signup latency is pre-paid: image pull, PVC provision, DSH boot all happen before any user exists | | `SandboxWarmPool` | Signup latency is pre-paid: image pull, PVC provision, DSH boot all happen before any user exists |
@@ -63,12 +66,13 @@ What it demonstrates, feature by feature:
upstream instead of splitting `/v1/*` (API) from the dashboard. upstream instead of splitting `/v1/*` (API) from the dashboard.
- **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional - **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional
`dsh-provider-keys-extra` Secret are injected via `envFrom` into every `dsh-provider-keys-extra` Secret are injected via `envFrom` into every
sandbox. There is no per-tenant key, dashboard basic-auth, session-cookie sandbox. There is no per-tenant key, dashboard basic-auth, signed-session
secret, or API-server key. secret (sessions are opaque server-side ids), or API-server key.
- **Launch-token handshake**: DSH gates `/` behind a token it prints only in its - **Launch-token handshake**: DSH gates `/` behind a token it prints only in its
logs. The gateway reads it, performs the handshake, and keeps the resulting logs. The gateway reads it, performs the handshake, and keeps the resulting
auth cookie server-side; the browser authenticates to the gateway with its own auth cookie server-side; the browser authenticates to the gateway with its own
cookie. See [How the DSH handshake works](#how-the-dsh-handshake-works). opaque session cookie, never the token. See
[How the DSH handshake works](#how-the-dsh-handshake-works).
- **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards - **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards
the browser's `Host`, so the public hostname must be added to the browser's `Host`, so the public hostname must be added to
`DSH_TRUSTED_HOSTS` in the template. `DSH_TRUSTED_HOSTS` in the template.
@@ -79,12 +83,14 @@ What it demonstrates, feature by feature:
## Prerequisites ## Prerequisites
Run the walkthrough from this example's directory (`examples/dsh-agents-as-a-service/`).
- A Kubernetes cluster with a default StorageClass (kind and GKE both work as-is). - A Kubernetes cluster with a default StorageClass (kind and GKE both work as-is).
- agent-sandbox with extensions installed: - agent-sandbox with extensions installed:
```bash ```bash
VERSION=v1.0.7 # AGENT_SANDBOX_VERSION in versions.env . ../../versions.env
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$VERSION/sandbox-with-extensions.yaml kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/sandbox-with-extensions.yaml"
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
``` ```
@@ -201,9 +207,30 @@ kubectl -n dsh-demo port-forward "pod/$SB" 18081:3080
## 4. Write state on the PVC ## 4. Write state on the PVC
```bash ```bash
kubectl -n dsh-demo exec "$SB" -- printenv HOME # /workspace
kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt' kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me too" > /workspace/marker.txt'
``` ```
`/workspace` (the DSH image's `WORKDIR`, where agents keep files) is the same
`dsh-home` PVC mounted again with `subPath: workspace`. The two trees stay
separate, so `.credentials.yaml` and the session store are never part of the
agent-visible workspace.
The template also sets `HOME=/workspace`. DSH's directory picker (the folder
explorer used to choose a workspace) has no start-path setting: when it opens,
its browse backend lists `$HOME`, so with the image default (`/home/node`, which
is not on a volume) it would start outside the persisted tree. Pointing `HOME`
at the workspace makes the picker open on it (labelled *Home* in its breadcrumbs)
and makes `~` in agent commands mean `/workspace`. The trade-off is that tools
keeping caches or config under `$HOME` (`npm`/`pnpm`, `uv`, `git`, `gh`, and
DSH's global `~/.agents` instructions and skills) write dotfiles onto the PVC
inside the agent workspace, where the agent and the file explorer can see them.
`DSH_HOME` is explicit (`/home/node/.dsh`), so sessions, config and credentials
are unaffected. Pod env is stamped when a sandbox is created: a sandbox from
before this setting keeps `HOME=/home/node` until it is recreated (see
[Troubleshooting](#troubleshooting)).
## 5. Suspend: delete the pod, keep the PVC + Service ## 5. Suspend: delete the pod, keep the PVC + Service
```bash ```bash
@@ -219,13 +246,12 @@ kubectl -n dsh-demo get svc "$SB" # still here
kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}' kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'
kubectl -n dsh-demo wait sandbox "$SB" --for=condition=Ready --timeout=180s kubectl -n dsh-demo wait sandbox "$SB" --for=condition=Ready --timeout=180s
kubectl -n dsh-demo exec "$SB" -- cat /home/node/.dsh/marker.txt # "remember me" kubectl -n dsh-demo exec "$SB" -- cat /home/node/.dsh/marker.txt # "remember me"
kubectl -n dsh-demo exec "$SB" -- cat /workspace/marker.txt # "remember me too"
``` ```
> **Why resume works:** the pod template sets `fsGroupChangePolicy: OnRootMismatch`. > **Why resume works:** the pod template sets `fsGroupChangePolicy: OnRootMismatch`;
> DSH requires its `.credentials.yaml` to be `0600`, while the kubelet's default > without it the kubelet widens `.credentials.yaml` to `0660` and DSH crash-loops.
> fsGroup policy widens every file to `0660` on each pod start — a resumed sandbox > See [Troubleshooting](#troubleshooting) for the one-time fix on older sandboxes.
> would crash-loop. See [Troubleshooting](#troubleshooting) for the one-time fix
> on sandboxes created before this setting existed.
## 7. Injection policy rejects env-carrying claims ## 7. Injection policy rejects env-carrying claims
@@ -244,13 +270,13 @@ kubectl delete -f manifests/40-claim-rejected.yaml
```bash ```bash
# Published image (on kind, load it into the cluster; on a real cluster the # Published image (on kind, load it into the cluster; on a real cluster the
# kubelet pulls it from GHCR): # kubelet pulls it from GHCR):
GW=ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4 GW=ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6
docker pull "$GW" docker pull "$GW"
kind load docker-image "$GW" --name <kind-cluster> kind load docker-image "$GW" --name <kind-cluster>
# ... or build it locally and tag it exactly as manifests/50-gateway.yaml # ... or build it locally and tag it exactly as manifests/50-gateway.yaml
# expects (make gateway builds dsh-aaas-gateway:dev): # expects (make gateway builds dsh-aaas-gateway:dev):
make gateway make -C ../.. gateway
docker tag dsh-aaas-gateway:dev "$GW" docker tag dsh-aaas-gateway:dev "$GW"
kind load docker-image "$GW" --name <kind-cluster> kind load docker-image "$GW" --name <kind-cluster>
@@ -261,6 +287,15 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
> The gateway binds `8080`; the local port-forward uses `18080` and never `3080`. > The gateway binds `8080`; the local port-forward uses `18080` and never `3080`.
> >
> **Cookie/TLS guard.** The base manifest serves plain HTTP through port-forward,
> so it sets `COOKIE_SECURE=false` **and** `ALLOW_INSECURE_COOKIES=1`. The gateway
> refuses to start when `COOKIE_SECURE=false` unless
> `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges that the session cookie
> travels in clear text; that prevents an HTTPS deployment from silently serving
> an insecure cookie. Before exposing the gateway over TLS, set
> `COOKIE_SECURE=true` and remove the acknowledgement:
> `kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
>
> **Image pull policy.** `manifests/50-gateway.yaml` uses > **Image pull policy.** `manifests/50-gateway.yaml` uses
> `imagePullPolicy: IfNotPresent` with the release identity, so a locally built > `imagePullPolicy: IfNotPresent` with the release identity, so a locally built
> image must be loaded into the cluster under exactly that name (kind: > image must be loaded into the cluster under exactly that name (kind:
@@ -293,25 +328,40 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
### Browser flow ### Browser flow
Open <http://localhost:18080>. The landing page has a one-field signup form. Open <http://localhost:18080>. The landing page has a one-field signup form (plus
Enter `bob`, and the gateway: an invite-code field when `SIGNUP_INVITE_CODE` is configured). Enter `bob`, and
the gateway:
1. creates the `SandboxClaim` over the warm pool, 1. enforces the auth rate limit and, if configured, the signup invite code,
2. waits for it to become `Ready`, 2. creates the `SandboxClaim` over the warm pool,
3. sets an `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<user>:<token>` on 3. waits for it to become `Ready`,
the browser, 4. mints a random, server-side session (the cookie value is an opaque `sid`, not
4. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway. a user name and not a token), stores `sid -> (user, expiry)` in memory, and
sets the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<sid>` on the
browser (`__Host-dsh_gw_session` when `COOKIE_SECURE=true`),
5. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway.
From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is
proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in
the gateway; the browser only holds the gateway cookie. the gateway; the browser only holds the opaque gateway session id. The cookie is
useless as a bearer token.
**Sessions are revocable and mostly short-lived in practice.** `POST /logout`,
deleting the user, `POST /recreate` and cookie expiry (default 7 days,
`COOKIE_MAX_AGE`) invalidate the server-side session. The session table lives in
gateway memory, so **restarting the gateway logs every browser out** — expected
for this single-replica demo; a production deployment needs a shared session
store and sticky routing or per-request re-authentication. With SSO enabled, the
gateway mints a session for the identity oauth2-proxy asserts instead of asking
for a token.
The same flow with `curl`, no JavaScript (the session cookie is the only The same flow with `curl`, no JavaScript (the session cookie is the only
credential): credential):
```bash ```bash
JAR=$(mktemp) JAR=$(mktemp)
# signup (form POST, exactly what the landing page sends) # signup (form POST, exactly what the landing page sends); add -d 'invite=...'
# when SIGNUP_INVITE_CODE is configured, or send X-Signup-Invite
curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \ curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \
-d 'user=bob' http://localhost:18080/users -d 'user=bob' http://localhost:18080/users
# proxy: bob's DSH Web through the gateway # proxy: bob's DSH Web through the gateway
@@ -320,52 +370,54 @@ curl -s -b "$JAR" http://localhost:18080/u/bob/ | head -5
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401 curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401
# derived state: Ready / Waking / Suspended / ... # derived state: Ready / Waking / Suspended / ...
curl -s -b "$JAR" http://localhost:18080/users/bob curl -s -b "$JAR" http://localhost:18080/users/bob
# delete bob (cascades sandbox + PVC) # sign out (revokes the server-side session)
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X POST http://localhost:18080/logout
# delete bob (cascades sandbox + PVC and revokes every session for bob)
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob
``` ```
API clients that prefer bearer tokens can use `POST /users` with a JSON body API clients that prefer bearer tokens can use `POST /users` with a JSON body
(`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a (`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a
token for a cookie, and `Authorization: Bearer <token>` on subsequent calls. token for a cookie, and `Authorization: Bearer <token>` on subsequent calls. A
cookie/session value is never accepted as a bearer token, and a token for one
user cannot read another user's path.
### Gateway API ### Auth abuse controls and proxy limits
| Route | Purpose | The gateway is a single process, so these limits are per replica. They stop
| --- | --- | casual abuse and one tenant monopolizing the service; an internet-facing
| `GET /healthz` | Liveness/readiness; no cluster access | deployment still needs ingress-level rate limiting and an audit trail.
| `GET /` | Landing page: signup, token login, logout. With SSO it auto-provisions and connects instead. |
| `POST /users` | Signup. JSON or form; `201` (JSON), `202` (waking), `303` (browser redirect), `409` if user exists; sets the session cookie |
| `POST /login` | Exchange a bearer token for the session cookie |
| `POST /logout` | Clear the session cookie |
| `POST /recreate` | SSO only: delete the authenticated user's claim (wiping workspace data) and provision a fresh one; `303` to `/u/{user}/`. Cookie-authenticated calls must be same-origin. |
| `GET /users/{user}` | Derived state (`Ready`/`Waking`/`Suspended`) |
| `DELETE /users/{user}` | Delete claim (cascades sandbox + PVC) |
| `GET /u/{user}` | `308` redirect to `/u/{user}/` |
| `GET /u/{user}/**` | HTTP/SSE proxy to the user's DSH Web |
| `WS /u/{user}/**` | WebSocket proxy to the user's DSH Web |
The full API reference, configuration table, auth semantics and failure matrix | Setting | Default | Behavior |
live in the [gateway README](gateway/README.md). | --- | --- | --- |
| `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | `30` per `60` s | Throttles `/users` and `/login` per client (rightmost `X-Forwarded-For` when behind a proxy, else the TCP peer) and answers `429` + `Retry-After` before reading the body or touching the cluster. `0` disables it. The base manifest sets it explicitly. |
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the code as form field `invite` or header `X-Signup-Invite` (constant-time compare, `403` before user lookup). Put it in a Secret and restart the gateway to rotate. |
| `MAX_IN_FLIGHT_PER_USER` | `32` | Caps concurrent proxied requests + WebSockets per user; excess HTTP gets `503` + `Retry-After`, excess WebSocket upgrades close with `1011`. |
| `WS_MAX_MESSAGE_BYTES` | `16 MiB` | Largest WebSocket message received from DSH; oversized frames close the connection. Browser-to-DSH frames are bounded by uvicorn's own limit. HTTP/SSE streaming is not capped by this. |
| `MAX_BODY_MB` | `32` | Largest proxied HTTP request body (`413`). |
The gateway also strips client-supplied `Forwarded`, `X-Forwarded-User` and
`X-Auth-Request-*` headers (and `x-forwarded-*` / `x-auth-request-*` prefixes)
before proxying, so a tenant cannot inject identity or spoof hop headers into
DSH. `UPSTREAM_HEADER_ALLOWLIST` can replace that denylist with an explicit
comma-separated allowlist. See the [gateway README](gateway/README.md) for the
full configuration.
### Gateway routes
The browser flow above uses the gateway's main routes. The full route table,
status codes, configuration and failure matrix live in the
[gateway README](gateway/README.md).
### How the DSH handshake works ### How the DSH handshake works
DSH does not expose an API for its launch token; it prints a URL containing DSH prints its launch token only in pod logs. The gateway resolves
`?token=…` in the pod logs. For each `(user, pod UID, Host)` the gateway: claim → sandbox → pod, reads the pod-log tail, performs the `?token=` handshake
under the browser's `Host`, caches `dsh-auth-*` per `(user, pod UID, Host)`, and
1. resolves the claim → sandbox → pod (annotation `agents.x-k8s.io/pod-name`, or refreshes once on a `401`. That is why its RBAC includes `pods` `get`/`list` and
the sandbox name as pod name), `pods/log` `get`; it is also the most fragile part of the design (see
2. reads the pod's logs (last `DSH_LOG_TAIL_LINES` lines) and parses the most [Limitations](#limitations) and the
recent `dsh web: …?token=…` line, [architecture ADR](docs/architecture.md#adr-3-server-side-launch-token-handshake-instead-of-exposing-the-token)).
3. requests `http://<serviceFQDN>:3080/?token=…` with the browser's `Host`,
4. captures DSH's `dsh-auth-*` `Set-Cookie` response and caches it,
5. injects that cookie (and strips the browser's `Cookie`/`Authorization` and any
`?token=`) on every proxied HTTP and WebSocket request,
6. refreshes the handshake once if DSH answers `401`, and drops the cached cookie
when the sandbox suspends or is deleted.
This is why the gateway's RBAC includes `pods` `get`/`list` and `pods/log`
`get`. It is also the most fragile part of the design; see
[Limitations](#limitations).
### DSH_TRUSTED_HOSTS ### DSH_TRUSTED_HOSTS
@@ -384,27 +436,21 @@ The same applies to a local `docker run`: the printed token URL uses
### WebSocket ### WebSocket
The gateway is a Starlette ASGI app and proxies WebSocket upgrades with a The gateway proxies WebSocket upgrades with a bidirectional pump, so the DSH web
bidirectional pump, so the DSH web GUI is fully interactive. Requirements: GUI is fully interactive. The entry point must pass `Upgrade`/`Connection`
- the ingress/load balancer in front of the gateway must pass `Upgrade`/`Connection`
headers; [`manifests/exposure/ingress/60-ingress.yaml`](manifests/exposure/ingress/60-ingress.yaml) headers; [`manifests/exposure/ingress/60-ingress.yaml`](manifests/exposure/ingress/60-ingress.yaml)
is a starting point (Traefik handles upgrades by default), is a starting point (Traefik handles upgrades by default). Idle suspension is
- idle suspension is disabled for users with in-flight proxied requests or open disabled while a proxied request or socket is in flight, so a long-running turn is
WebSockets, so a long-running chat stream is not cut off mid-turn, not cut off.
- the upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`,
and performs the launch-token handshake under that same `pod-IP:3080` authority. The upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`,
DSH fences on `Host` (only its bind address, loopback, or an allow-listed host and performs the launch-token handshake under the same `pod-IP:3080` authority.
passes) and binds its auth cookie to the request authority Missing or cross-origin auth closes with `4401`; upstream bridging failures close
(`payload.authority === authority`), while `websockets` derives `Host` from the with `1011`, as does exceeding `MAX_IN_FLIGHT_PER_USER` (per-user socket cap).
URI and cannot be overridden. A browser-host cookie or forwarded origin makes Messages received from DSH larger than `WS_MAX_MESSAGE_BYTES` (16 MiB by
the upgrade fail (`401`/`403`, surfaced as close `1011`), which the client default) close the connection instead of being buffered; browser-to-DSH frames
retries forever as `[connection] connection lost, retry #N`, are bounded by uvicorn's own limit. See the
- missing/invalid gateway auth is rejected right after the upgrade with close code [gateway README](gateway/README.md#proxy-failure-semantics) for the full matrix.
`4401` (WebSocket auth has no HTTP `401` path); cross-origin or Origin-less
upgrades with the cookie are rejected the same way (CSWSH protection), so
clients must send a matching `Origin`; upstream bridging failures close with
`1011`.
### Path routing ### Path routing
@@ -428,33 +474,27 @@ not a stack. Details, commands and the switch procedure are in the
Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits
gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml`
limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS
and the gateway. They are part of the variant; see the and the gateway. They are part of the variant **and required for production**:
[exposure README](manifests/exposure/README.md) and an exposure applied without its policy is demo-only. The sandbox side needs a
[optional hardening](docs/optional-hardening.md). separate policy, and the gateway needs an egress policy; both are covered in
[optional hardening](docs/optional-hardening.md) (see the production checklist)
and summarized in the [exposure README](manifests/exposure/README.md).
### Option A — plain Ingress (gateway's own session auth) ### Option A — plain Ingress (gateway's own session auth)
```bash 1. Set the Ingress host/TLS (or drop the `tls:` block), and set
# Replace the dsh.example.com host and TLS Secret (or drop the tls: block for `DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same public
# plain HTTP), then: host; re-apply the template.
kubectl apply -f manifests/exposure/ingress/ 2. Set `COOKIE_SECURE=true` and drop the plain-HTTP acknowledgement once the
``` Ingress serves HTTPS:
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
1. Set `DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same 3. `kubectl apply -f manifests/exposure/ingress/`; verify
public host (the gateway forwards the browser's `Host`) and re-apply the `curl -sI https://<host>/healthz` returns `200`.
template.
2. Set `COOKIE_SECURE=true` on the gateway once the Ingress serves HTTPS:
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`.
(The base manifest keeps `false` so the plain-HTTP port-forward demo works.)
3. Verify: `curl -sI https://<host>/healthz` returns `200`; sign up in a browser
at `https://<host>/`.
4. `manifests/exposure/ingress/61-networkpolicy.yaml` is part of this variant:
it allows only Traefik pods to reach the gateway on `8080`. Adjust its
selector if your ingress controller uses a different pod label or
`hostNetwork`; if kubelet probes fail, see the hardening doc.
Keep the router free of buffering/compression middlewares: they break WebSocket Keep the router free of buffering/compression middlewares: they break WebSocket
upgrades and streaming. upgrades and streaming. The variant's `61-networkpolicy.yaml` allows only Traefik
pods to reach the gateway; see the [exposure README](manifests/exposure/README.md)
for selector and probe caveats.
### Option B — Gateway API + GitHub SSO (oauth2-proxy) ### Option B — Gateway API + GitHub SSO (oauth2-proxy)
@@ -464,99 +504,86 @@ front of **oauth2-proxy**, which authenticates the browser against GitHub
`X-Forwarded-User` set. The gateway trusts that header only when `X-Forwarded-User` set. The gateway trusts that header only when
`AUTH_PROXY_HEADER` is set (SSO is **opt-in**). `AUTH_PROXY_HEADER` is set (SSO is **opt-in**).
Prerequisites: 1. Install Gateway API CRDs and a Gateway implementation; the manifests assume
`gatewayClassName: traefik` (the CRD version is `GATEWAY_API_VERSION` in
- Gateway API CRDs and a Gateway implementation; the manifests assume `versions.env`).
`gatewayClassName: traefik` (the CRD version is pinned as 2. Create a GitHub OAuth App whose homepage/callback match the public FQDN, and
`GATEWAY_API_VERSION` in `versions.env`). create the oauth2-proxy and TLS Secrets from
- A GitHub OAuth App whose homepage/callback match the public FQDN `73-oauth2-proxy-secret.yaml.example` and `74-tls-secret.yaml.example`.
(`https://<host>/` and `https://<host>/oauth2/callback`). 3. Replace `dsh-demo.example.com` and `your-github-org` in the variant
- The oauth2-proxy credentials Secret (template: manifests, set `DSH_TRUSTED_HOSTS` in the template, and enable SSO:
`73-oauth2-proxy-secret.yaml.example`) and a TLS Secret for the Gateway `kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
listener (template: `74-tls-secret.yaml.example`). 4. `kubectl apply -f manifests/exposure/gateway-api-sso/`; then run
[`scripts/preflight-sso.sh`](scripts/preflight-sso.sh) and fix everything it
Steps: reports **before** pointing real traffic at the Gateway. It verifies the
Secrets (including a valid oauth2-proxy cookie secret length), the gateway's
```bash `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, that the Gateway's TLS certificate
# 1. Real Secrets; the committed *.yaml.example files are templates only. reference resolves, and that no `your-github-org`/`dsh-demo.example.com`
kubectl -n dsh-demo create secret generic oauth2-proxy \ placeholder was left applied. Full verification steps are in the
--from-literal=client-id='<GitHub OAuth App client ID>' \ [exposure README](manifests/exposure/README.md).
--from-literal=client-secret='<GitHub OAuth App client secret>' \
--from-literal=cookie-secret="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
kubectl -n dsh-demo create secret tls dsh-demo-tls --cert=tls.crt --key=tls.key
# 2. Replace dsh-demo.example.com (70/71/72) and your-github-org (72).
# 3. Enable SSO on the gateway and trust the public host in the template.
kubectl -n dsh-demo set env deploy/dsh-gateway \
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true
# ... and set DSH_TRUSTED_HOSTS=dsh-demo.example.com in
# manifests/10-sandboxtemplate.yaml, then re-apply the template.
# 4. Apply the variant.
kubectl apply -f manifests/exposure/gateway-api-sso/
```
Verification:
```bash
kubectl get gateway dsh-gateway -n dsh-demo -o yaml # Programmed=True
kubectl get httproute -n dsh-demo -o wide # backend oauth2-proxy:4180, Accepted
kubectl get deploy oauth2-proxy -n dsh-demo # 1/1 Ready
kubectl get endpoints oauth2-proxy -n dsh-demo # backend present
kubectl -n dsh-demo logs deploy/oauth2-proxy --tail=50 # no config errors
```
Browser check: open `https://<host>/` — you are redirected to GitHub, and after
signing in as a member of the allowed organization the gateway auto-provisions
your sandbox (the first page can show `Provisioning`) and connects you. A
non-member is denied before reaching the gateway. GitHub logins are lowercase
DNS-1123 labels, so they map 1:1 onto the gateway's `user` validation. The SSO
landing page also offers **Recreate sandbox** (`POST /recreate`), which deletes
the claim — wiping the workspace PVC — and provisions a fresh one.
Sign-out: the gateway `/logout` clears `dsh_gw_session`; oauth2-proxy's session
can be cleared at `/oauth2/sign_out`.
> **Exposure without the variant NetworkPolicy is demo-only.** The SSO
> variant's `75-networkpolicies.yaml` is not optional in practice: it is the
> only thing that makes oauth2-proxy the sole path to the gateway. Applying
> only the Gateway/HTTPRoute/oauth2-proxy objects leaves the trust boundary
> unenforced, so anyone who can reach `dsh-gateway:8080` can send
> `X-Forwarded-User` and impersonate the user they claim to be.
>
> **SSO trust boundary — read this before enabling it.** After SSO is on, the > **SSO trust boundary — read this before enabling it.** After SSO is on, the
> `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is > `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is
> ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic > ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic
> accordingly: the gateway accepts traffic only from oauth2-proxy pods, > accordingly: the gateway accepts traffic only from oauth2-proxy pods,
> oauth2-proxy only from the Gateway implementation, and oauth2-proxy egress is > oauth2-proxy only from the Gateway implementation, and its egress only to DNS,
> limited to DNS, public HTTPS and the gateway. Anyone who can reach > public HTTPS and the gateway. The gateway's own egress (Kubernetes API,
> `dsh-gateway:8080` directly can still send `X-Forwarded-User: <victim>` and be > sandbox pods) is not restricted by that file because the API-server endpoint is
> treated as that user, so never add a second path (the Ingress variant, a > cluster-specific; add the
> shared-host port-forward, a permissive policy). To turn SSO off, remove the > [gateway egress policy](docs/optional-hardening.md#gateway-egress-required-for-production)
> variable (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`) > for production. Anyone who can reach `dsh-gateway:8080` directly can send
> and delete the SSO variant; the gateway falls back to bearer-token/session > `X-Forwarded-User: <victim>`, so never add a second path. To turn SSO off,
> auth. > remove the variable
> (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`) and
> delete the SSO variant.
### Switching variants ### Switching variants
Delete the variant you had before applying the other one Delete the variant you had before applying the other one, revert
(`kubectl delete -f manifests/exposure/ingress/`), revert
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, and verify with `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, and verify with
`kubectl -n dsh-demo get ingress,gateway,httproute` that exactly one path to the `kubectl -n dsh-demo get ingress,gateway,httproute` that exactly one path to the
gateway remains. gateway remains. Full commands and verification are in the
[exposure README](manifests/exposure/README.md).
## Optional hardening ## Optional hardening
The base walkthrough is deliberately portable. Four opt-in layers are documented The base walkthrough is deliberately portable, which means it is **not
in [optional hardening](docs/optional-hardening.md): hardened**: without the policies and gVisor it is demo-only, not a safe
multi-tenant platform. [Optional hardening](docs/optional-hardening.md) has the
settings and a production checklist. In short:
- **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an - **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an
existing install. Requires a `gvisor` RuntimeClass and capable nodes. existing install. Requires a `gvisor` RuntimeClass and capable nodes.
`kubectl port-forward` does not work under gVisor; the gateway path does. `kubectl port-forward` does not work under gVisor; the gateway path does.
Required for untrusted agents in production.
- **nodeSelector** — uncomment and replace the placeholder node label to pin - **nodeSelector** — uncomment and replace the placeholder node label to pin
sandboxes and the gateway to a dedicated pool; add a toleration for tainted sandboxes and the gateway to a dedicated pool; add a toleration for tainted
pools. pools.
- **Sandbox NetworkPolicy** — - **Sandbox NetworkPolicy** —
`kubectl apply -f manifests/optional/network-policy/` allows only `kubectl apply -f manifests/optional/network-policy/` allows only
gateway→sandbox on `3080` and sandbox→DNS/public `443`. gateway→sandbox on `3080` and sandbox→DNS/public `443`. Required for
production; exposure without it is demo-only.
- **Gateway egress policy** — the gateway needs DNS, the Kubernetes API and
`3080` to sandbox pods. It is not shipped because the API-server endpoint is
cluster-specific; use the
[recipe](docs/optional-hardening.md#gateway-egress-required-for-production).
Required for production.
- **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1. - **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1.
- **Failed-Pod recovery** — uncomment `failedPodPolicy: Recreate` so the
controller replaces a `Failed` sandbox Pod (node eviction) instead of parking
the sandbox at `Finished=True`. Requires agent-sandbox v1.0.8+.
Apply each independently and re-run the walkthrough. `kubectl apply -f manifests/` The **exposure variants' NetworkPolicies** are not optional in production
is non-recursive, so the optional manifest is not applied by the base step. either; see step 9. Apply each setting independently and re-run the walkthrough.
`kubectl apply -f manifests/` is non-recursive, so the optional manifests are not
applied by the base step.
## Troubleshooting ## Troubleshooting
@@ -565,17 +592,26 @@ is non-recursive, so the optional manifest is not applied by the base step.
| Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` | | Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` |
| Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` | | Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` |
| `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` | | `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` |
| `503` with `Retry-After` | The launch token was not found in the pod logs yet; the gateway retries after a short delay. Check the pod logs and `DSH_LOG_TAIL_LINES` | | `503` with `Retry-After` | Either the launch token was not found in the pod logs yet (the gateway retries after a short delay; check the pod logs and `DSH_LOG_TAIL_LINES`) or the user hit `MAX_IN_FLIGHT_PER_USER` (default 32 concurrent requests/WebSockets); reduce client concurrency or raise the limit deliberately |
| `429` with `Retry-After` on signup/login | The per-client auth rate limit (`AUTH_RATE_LIMIT`, default explicitly `30` per `AUTH_RATE_WINDOW=60` s) rejected the attempt before reading the body. Wait for the window, or raise the limit; behind a proxy make sure the real client IP is the rightmost `X-Forwarded-For` entry, not a shared NAT address |
| `403` on signup: invite required/invalid | `SIGNUP_INVITE_CODE` is configured. Send the code as form field `invite` or header `X-Signup-Invite`; the landing page shows the field when enabled. A wrong code is rejected before the user lookup |
| Gateway pod `CrashLoopBackOff` with `refusing to start with an insecure session cookie` | Intentional guard: plain HTTP is only allowed when explicitly acknowledged. For the local demo set `ALLOW_INSECURE_COOKIES=1` (the base manifest does); for HTTPS set `COOKIE_SECURE=true` and remove `ALLOW_INSECURE_COOKIES` |
| Every browser is logged out after a gateway rollout/restart | Expected: sessions are opaque ids in gateway memory and a restart drops them. Sign in again; a production deployment needs shared session state or per-request re-authentication. The DSH sandboxes and their data are unaffected |
| WebSocket closes with `1011` for a busy client | `MAX_IN_FLIGHT_PER_USER` (per-user connection cap) or an upstream/handshake failure closed the socket; check the gateway logs to tell them apart |
| WebSocket closes after a very large message from DSH | A message received from DSH exceeded `WS_MAX_MESSAGE_BYTES` (16 MiB), so the relay closed it instead of buffering; raise the limit only if DSH legitimately sends larger frames. Browser-to-DSH frames are bounded by uvicorn's own limit |
| `scripts/preflight-sso.sh` exits non-zero | It names the failing item: a missing Secret/key, a bad oauth2-proxy cookie-secret length, missing `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, a dangling Gateway TLS reference, a leftover `your-github-org`/`dsh-demo.example.com` placeholder, or a second path to the gateway (an Ingress). Fix all items before exposing the SSO variant |
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first | | `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` for local demos | | Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` **and** `ALLOW_INSECURE_COOKIES=1` for local demos |
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` | | WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
| Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop | | Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop |
| `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost | | `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost |
| Signup is slow / `202` then `Waking` | Warm pool exhausted (`kubectl get sandboxes -l agents.x-k8s.io/warm-pool-sandbox`); scale the pool | | Signup is slow / `202` then `Waking` | Warm pool exhausted (`kubectl get sandboxes -l agents.x-k8s.io/warm-pool-sandbox`); scale the pool |
| `403` from the Kubernetes API in gateway logs | The gateway ServiceAccount is missing claim/sandbox/pod-log verbs (see `manifests/50-gateway.yaml`) | | `403` from the Kubernetes API in gateway logs | The gateway ServiceAccount is missing claim/sandbox/pod-log verbs (see `manifests/50-gateway.yaml`) |
| Gateway pod `ErrImagePull` / `ImagePullBackOff` (especially on kind) | The manifest uses `imagePullPolicy: IfNotPresent` with the release identity. Build with `make gateway`, retag to exactly the manifest's image, `kind load docker-image <that-ref>`, or set the Deployment image to your local tag / pinned digest. On a real cluster, make sure the GHCR package is readable (public or a pull secret) | | Gateway pod `ErrImagePull` / `ImagePullBackOff` (especially on kind) | The manifest uses `imagePullPolicy: IfNotPresent` with the release identity. Build with `make -C ../.. gateway`, retag to exactly the manifest's image, `kind load docker-image <that-ref>`, or set the Deployment image to your local tag / pinned digest. On a real cluster, make sure the GHCR package is readable (public or a pull secret) |
| Step 3: `pod "$SB" not found` after deploying the gateway | The claim was suspended by the idle sweeper. Claims created by the gateway carry `aaas.example.com/managed-by=dsh-gateway`; hand-applied claims (steps 1–7) do not and are never swept. Resume the sandbox: `kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'` | | Step 3: `pod "$SB" not found` after deploying the gateway | The claim was suspended by the idle sweeper. Claims created by the gateway carry `aaas.example.com/managed-by=dsh-gateway`; hand-applied claims (steps 1–7) do not and are never swept. Resume the sandbox: `kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'` |
| Resume crash-loops: `credentials-local: … is readable beyond its owner (mode 660)` | The pod template lacks `fsGroupChangePolicy: OnRootMismatch`, so the kubelet's default fsGroup policy rewrote `.credentials.yaml` to `0660`; DSH requires `0600` and refuses to start. New installs get the policy from the template. For an existing sandbox: suspend it, run a throwaway pod on `dsh-home-$SB` to `chmod 600 /home/node/.dsh/.credentials.yaml`, patch the sandbox's `podTemplate` with the policy, then resume | | Resume crash-loops: `credentials-local: … is readable beyond its owner (mode 660)` | The pod template lacks `fsGroupChangePolicy: OnRootMismatch`, so the kubelet's default fsGroup policy rewrote `.credentials.yaml` to `0660`; DSH requires `0600` and refuses to start. New installs get the policy from the template. For an existing sandbox: suspend it, run a throwaway pod on `dsh-home-$SB` to `chmod 600 /home/node/.dsh/.credentials.yaml`, patch the sandbox's `podTemplate` with the policy, then resume |
| Agents get `Permission denied` writing to `/workspace` on a fresh or resumed pod | The `/workspace` mount is the `dsh-home` PVC's `workspace/` subPath, and the kubelet is known to leave a `subPath` directory at `0755` if it is interrupted between creating and chmodding it ([kubernetes#142464](https://github.com/kubernetes/kubernetes/issues/142464)); later mounts never correct it. Suspend the sandbox, run a throwaway pod on `dsh-home-$SB` mounted at `/data`, `chmod 2775 /data/workspace` (do **not** chown/chmod recursively — that rewrites `.credentials.yaml`), then resume. If you recreate the sandbox instead, its current `/workspace` contents go with the PVC |
| Every bash command fails: `SANDBOX_UNAVAILABLE`, `sandbox mode "workspace-write" is requested but no sandbox backend is usable` | DSH's own command sandbox found neither backend: `bubblewrap` ships in the image but is unsetuid, so it needs an unprivileged user namespace this hardened pod does not grant, and no Landlock runner exists on this kernel/runtime — the confined executor fails closed. The template sets `DSH_PERMISSION_MODE=danger-full-access` to keep agents usable inside the pod boundary. To restore confinement, confirm a backend inside the pod (`bwrap --ro-bind / / true`; `grep -o landlock /sys/kernel/security/lsm`), set `DSH_PERMISSION_MODE=workspace-write` (or `read-only`), and recreate the pod — the value is stamped at creation. A session that explicitly chose a mode keeps that choice |
| SSO: GitHub login succeeds but the gateway asks for a token / returns `401` to `/recreate` | `AUTH_PROXY_HEADER` is not set on `deploy/dsh-gateway` (SSO is opt-in). Set `AUTH_PROXY_HEADER=X-Forwarded-User` and confirm the request actually reaches the gateway through oauth2-proxy | | SSO: GitHub login succeeds but the gateway asks for a token / returns `401` to `/recreate` | `AUTH_PROXY_HEADER` is not set on `deploy/dsh-gateway` (SSO is opt-in). Set `AUTH_PROXY_HEADER=X-Forwarded-User` and confirm the request actually reaches the gateway through oauth2-proxy |
| SSO: gateway treats a caller as another user / bad `X-Forwarded-User` accepted | Trust boundary violation: something other than oauth2-proxy can reach `dsh-gateway:8080`. Remove the second path (Ingress/Gateway/port-forward), keep the Service ClusterIP, and only re-enable SSO once the proxy is the sole entry point. Never put a trusted identity header in front of a service that is directly reachable | | SSO: gateway treats a caller as another user / bad `X-Forwarded-User` accepted | Trust boundary violation: something other than oauth2-proxy can reach `dsh-gateway:8080`. Remove the second path (Ingress/Gateway/port-forward), keep the Service ClusterIP, and only re-enable SSO once the proxy is the sole entry point. Never put a trusted identity header in front of a service that is directly reachable |
| SSO: oauth2-proxy `cookie_secret must be 16, 24, or 32 bytes ... but is 44 bytes` | The cookie secret was generated as standard base64. Use URL-safe unpadded base64 (see `73-oauth2-proxy-secret.yaml.example`) | | SSO: oauth2-proxy `cookie_secret must be 16, 24, or 32 bytes ... but is 44 bytes` | The cookie secret was generated as standard base64. Use URL-safe unpadded base64 (see `73-oauth2-proxy-secret.yaml.example`) |
@@ -586,14 +622,27 @@ is non-recursive, so the optional manifest is not applied by the base step.
| Sandbox cannot reach a model API after applying the optional NetworkPolicy | The policy allows DNS and public TCP `443` only; a proxy, custom base URL, port `80` or non-443 endpoint needs an explicit egress rule | | Sandbox cannot reach a model API after applying the optional NetworkPolicy | The policy allows DNS and public TCP `443` only; a proxy, custom base URL, port `80` or non-443 endpoint needs an explicit egress rule |
| Gateway cannot reach a sandbox after applying an exposure NetworkPolicy | Check that the CNI enforces NetworkPolicy and that the Traefik/oauth2-proxy pod labels match; kubelet probes may also need the node CIDR | | Gateway cannot reach a sandbox after applying an exposure NetworkPolicy | Check that the CNI enforces NetworkPolicy and that the Traefik/oauth2-proxy pod labels match; kubelet probes may also need the node CIDR |
| New provider key not visible in a sandbox | Pod env is stamped at creation: delete the warm-pool spares and recreate claimed pods. A key saved in DSH's credential store also wins over env | | New provider key not visible in a sandbox | Pod env is stamped at creation: delete the warm-pool spares and recreate claimed pods. A key saved in DSH's credential store also wins over env |
| Folder picker opens on `/home/node` instead of the workspace | The sandbox was created before the template set `HOME=/workspace`, and pod env is stamped at creation. Verify with `kubectl -n dsh-demo exec "$SB" -- printenv HOME`; patch the sandbox's `podTemplate` with the env (or recreate the sandbox — the PVC keeps DSH state and workspace) and resume |
| File explorer and `ls /workspace` show `.cache`, `.config`, `.local`, `.npm`, `.agents` | Expected with `HOME=/workspace`: `~`-relative tool caches and DSH's global agent instructions/skills live on the PVC inside the workspace. Remove the `HOME` env from the template (and recreate the sandbox) if you would rather keep the picker on the ephemeral image home; point caches elsewhere per tool if only they are unwanted |
| Sandbox reports `Finished=True` and every gateway request fails | The sandbox Pod reached phase `Failed` (node eviction, spot reclaim) and the default `Retain` keeps it. Delete the Pod so the controller recreates it, or set `failedPodPolicy: Recreate` (agent-sandbox v1.0.8+; see optional hardening) |
## Limitations ## Limitations
Deliberately **not** production code: Deliberately **not** production code:
- **Single replica.** Idle clocks, in-flight tracking, and the handshake cache - **Single replica.** Idle clocks, in-flight tracking, the handshake cache and
are in memory. Running two gateway replicas would double-suspend or lose wake the browser session table are in memory. Running two gateway replicas would
tracking; scale up only after moving that state to a shared store. double-suspend or lose wake tracking, and sessions would land on the wrong
replica; scale up only after moving that state to a shared store.
- **Sessions die with the process.** Browser sessions are opaque ids held in
gateway memory (revocable on logout/delete/recreate, but not durable). A
gateway restart logs every browser out. There is no SSO refresh-token or
password store; identity is re-established by oauth2-proxy on the next
request.
- **Abuse controls are per-process and coarse.** `AUTH_RATE_LIMIT`, the signup
invite code, `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` limit casual
abuse, but they are not a substitute for ingress rate limiting, WAF/quota, or
an audit log, and the in-memory rate buckets reset on restart.
- **Log scraping.** The launch token is recovered from pod logs. Log rotation, a - **Log scraping.** The launch token is recovered from pod logs. Log rotation, a
restarted pod, or a changed DSH log format can break the handshake until the restarted pod, or a changed DSH log format can break the handshake until the
next `401` refresh. next `401` refresh.
@@ -603,30 +652,41 @@ Deliberately **not** production code:
but the platform does not manage that. Real multi-tenancy needs per-tenant but the platform does not manage that. Real multi-tenancy needs per-tenant
credentials or an egress proxy. credentials or an egress proxy.
- **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set - **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set
`COOKIE_SECURE=true`. `COOKIE_SECURE=true`, then remove `ALLOW_INSECURE_COOKIES`. The base manifest
runs plain HTTP for the port-forward demo and is demo-only.
- **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header. - **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header.
The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway
to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a
custom exposure without that policy has no signature, mTLS or network custom exposure without that policy has no signature, mTLS or network
enforcement. enforcement. Exposure without the policy is demo-only.
- **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy and the exposure - **DSH's inner file sandbox is off here.** The template sets
policies are opt-in; without them, sandboxes share the host kernel and can `DSH_PERMISSION_MODE=danger-full-access` because neither backend of DSH's own
reach the cluster network. The base example is a demo, not a hardened command sandbox is usable in this pod: `bubblewrap` is installed in the image
multi-tenant platform. but is unsetuid, so it needs an unprivileged user namespace that the pod's
seccomp/capability surface does not grant (`capabilities.drop: ["ALL"]`), and
the kernel/runtime exposes no Landlock runner. The executor therefore fails
closed with `SANDBOX_UNAVAILABLE` and every bash command fails. The per-user
pod remains the boundary — non-root,
capabilities dropped, `seccompProfile: RuntimeDefault`, its own PVC and Service
— but an agent command can now read its own `DSH_HOME` (including
`.credentials.yaml`) and write anywhere in the container. Run sandboxes under
gVisor in production. On hosts where a backend works, set `workspace-write`
(or `read-only`) and verify before relying on the inner policy.
- **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy, the gateway egress
policy and the exposure policies are opt-in; without them, sandboxes share the
host kernel and can reach the cluster network. The base example is a demo, not
a hardened multi-tenant platform. See the
[production checklist](docs/optional-hardening.md#6-production-checklist).
- **OAuth org is the only authorization.** Any GitHub user in the configured - **OAuth org is the only authorization.** Any GitHub user in the configured
organization gets an auto-provisioned sandbox; there is no per-user allow-list, organization gets an auto-provisioned sandbox; there is no per-user allow-list,
quota, or admin API. quota, or admin API. `SIGNUP_INVITE_CODE` gates the gateway's own signup form,
not SSO auto-provisioning.
- **In-memory user directory.** Users are claims + annotations; there is no - **In-memory user directory.** Users are claims + annotations; there is no
database, password reset, or admin API. Tokens are stored only as SHA-256 database, password reset, or admin API. Tokens are stored only as SHA-256
hashes in claim annotations. hashes in claim annotations.
- **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the - **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the
browser flow uses the `HttpOnly` cookie instead. browser flow uses the `HttpOnly` cookie instead. The cookie no longer contains
the token, and a cookie value is never accepted as a bearer token.
## Cost model
The same economics as the upstream pattern: suspend-when-idle plus warm pools turn
an always-on instance into a sub-second cold-start pause per return-after-absence,
at a fraction of the cost.
## Cleanup ## Cleanup
@@ -648,5 +708,8 @@ delete the Secrets with the namespace and revoke the OAuth App in GitHub.
- [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs, - [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs,
security and RBAC, failure modes, evolution path. security and RBAC, failure modes, evolution path.
- [Exposure README](manifests/exposure/README.md) — the two variants in detail. - [Exposure README](manifests/exposure/README.md) — the two variants in detail.
- [SSO preflight](scripts/preflight-sso.sh) — read-only checks for the SSO
variant (Secrets, gateway env, TLS reference, placeholders, second paths).
- [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector, - [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector,
NetworkPolicy and extra provider keys, with verification and rollback. NetworkPolicy, extra provider keys and failed-Pod recovery, with verification,
the production checklist and rollback.

View File

@@ -6,7 +6,8 @@ Kubernetes, and DSH Web fit together, and why the design looks the way it does.
Scope: one Kubernetes namespace (`dsh-demo`), one shared DSH Web image, one gateway deployment, Scope: one Kubernetes namespace (`dsh-demo`), one shared DSH Web image, one gateway deployment,
and one DSH Web pod + PVC per user. The cluster is assumed to run and one DSH Web pod + PVC per user. The cluster is assumed to run
[agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) v1.0.7 with the extensions [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) with the pinned
`AGENT_SANDBOX_VERSION` from [`versions.env`](../../../versions.env) and the extensions
(`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`). (`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`).
## Container view ## Container view
@@ -24,7 +25,7 @@ flowchart LR
sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"] sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"]
svc["Headless Service<br/>status.serviceFQDN"] svc["Headless Service<br/>status.serviceFQDN"]
pod["DSH Web pod<br/>node process :3080"] pod["DSH Web pod<br/>node process :3080"]
pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh")] pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh + /workspace (subPath)")]
end end
browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw
@@ -46,7 +47,7 @@ Responsibilities:
| **Gateway** | Signup/login, session auth, `SandboxClaim` lifecycle, wake-on-connect, idle sweeper, HTTP/SSE/WebSocket reverse proxy, DSH launch-token handshake. | | **Gateway** | Signup/login, session auth, `SandboxClaim` lifecycle, wake-on-connect, idle sweeper, HTTP/SSE/WebSocket reverse proxy, DSH launch-token handshake. |
| **Kubernetes API** | Source of truth for claims, sandboxes, and pods; also the log source for the DSH launch token. | | **Kubernetes API** | Source of truth for claims, sandboxes, and pods; also the log source for the DSH launch token. |
| **agent-sandbox controller** | Adopts warm spares on claim, flips `operatingMode` (Running/Suspended), garbage-collects sandbox + PVC on claim delete. | | **agent-sandbox controller** | Adopts warm spares on claim, flips `operatingMode` (Running/Suspended), garbage-collects sandbox + PVC on claim delete. |
| **DSH Web pod** | One `dsh web` process per user, state on the per-user PVC, listening on `3080`. | | **DSH Web pod** | One `dsh web` process per user, DSH state and the agent workspace on the per-user PVC (`/home/node/.dsh` + `/workspace`), listening on `3080`. |
| **Warm pool** | Keeps `N` Ready sandboxes so signup is a label flip instead of a cold start. | | **Warm pool** | Keeps `N` Ready sandboxes so signup is a label flip instead of a cold start. |
## Exposure variants ## Exposure variants
@@ -71,8 +72,10 @@ Each variant also ships a NetworkPolicy: `ingress/61-networkpolicy.yaml` limits
to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to
oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to
DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic
enforcement; the caveats (Traefik label, `hostNetwork`, kubelet probes) are in enforcement. These policies are **required for production**: exposing the gateway by applying
[optional hardening](optional-hardening.md). only the Gateway/HTTPRoute/Ingress objects is demo-only. The sandbox-side policy and the
gateway's own egress policy are separate (the API-server endpoint is cluster-specific); see
[optional hardening](optional-hardening.md#gateway-egress-required-for-production).
## Flows ## Flows
@@ -86,20 +89,22 @@ sequenceDiagram
participant K as Kubernetes API participant K as Kubernetes API
participant C as agent-sandbox controller participant C as agent-sandbox controller
B->>G: POST /users (form: user=bob, Origin) B->>G: POST /users (form: user=bob, invite, Origin)
G->>G: validate user name and body size G->>G: rate limit, invite check, validate user name and body size
G->>K: create SandboxClaim dsh-bob (warmPoolRef) G->>K: create SandboxClaim dsh-bob (warmPoolRef)
K->>C: reconcile claim K->>C: reconcile claim
C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
G->>K: read claim (status) + sandbox + pod G->>K: read claim (status) + sandbox + pod
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session G->>G: mint opaque sid, store sid -> (user, expiry)
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session=<sid>
Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict) Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict)
``` ```
Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the
first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409` first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409`
means the user already has a claim, in which case `POST /login` (bearer token) or the existing means the user already has a claim, in which case `POST /login` (bearer token) or the existing
cookie is the right path. cookie is the right path. The cookie value is an opaque session id: it is meaningless outside
this gateway process and is never accepted as a bearer token.
### First request / proxy (with handshake) ### First request / proxy (with handshake)
@@ -111,8 +116,8 @@ sequenceDiagram
participant K as Kubernetes API participant K as Kubernetes API
participant D as DSH Web pod participant D as DSH Web pod
B->>G: GET /u/bob/ (Cookie dsh_gw_session) B->>G: GET /u/bob/ (Cookie dsh_gw_session=<sid>)
G->>G: authenticate cookie, user = bob G->>G: look up sid -> session (valid, not expired), user = bob
G->>K: resolve claim -> sandbox -> pod G->>K: resolve claim -> sandbox -> pod
alt sandbox Suspended alt sandbox Suspended
G->>K: patch operatingMode=Running (wake) G->>K: patch operatingMode=Running (wake)
@@ -135,6 +140,36 @@ Handshake cache behavior: one entry per `(user, pod UID, Host)`; refreshed once
`401`; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth `401`; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth
cookie never reach the browser. cookie never reach the browser.
Inside `proxy_http` the order is auth, in-flight slot, sandbox resolution/wake, DSH handshake,
body read, upstream send. An upload aborted mid-body may therefore already have paid for a wake
and a handshake, but it never reaches DSH.
### Browser sessions
The browser never holds a DSH credential. On signup/login the gateway mints a random session id
(`secrets.token_urlsafe(32)`), stores `sid -> Session(user, expires_at)` in memory, and sets it
as the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session` (`__Host-dsh_gw_session` when
`COOKIE_SECURE=true`). Every browser request is resolved through that table; the cookie value is
not a bearer token and cannot be replayed against another user's path.
Sessions end on explicit logout, user delete, `/recreate` (all sessions for that user) and
expiry (`COOKIE_MAX_AGE`, default 7 days). Because the table is process memory, a gateway
restart logs every browser out; scaling beyond one replica therefore requires a shared session
store (or re-authentication on every request). The sweeper prunes expired entries and sessions
for users whose claim no longer exists.
### Auth abuse controls and proxy limits
Unauthenticated endpoints are throttled before the body is read: `AUTH_RATE_LIMIT` attempts per
client per `AUTH_RATE_WINDOW` (defaults 30/60s; keyed on the rightmost `X-Forwarded-For` entry
when present, else the peer address), answered with `429` + `Retry-After`. When
`SIGNUP_INVITE_CODE` is set, signup additionally requires a constant-time-matched code (form
field or `X-Signup-Invite`) and answers `403` otherwise. Proxied traffic is capped per user
(`MAX_IN_FLIGHT_PER_USER`, default 32 concurrent HTTP requests + WebSockets: `503` for HTTP,
close `1011` for WebSocket) and per WebSocket message received from DSH
(`WS_MAX_MESSAGE_BYTES`, 16 MiB). These are in-process guards for a single replica, not an ingress
rate limiter or quota system.
### Wake-on-connect and idle suspend ### Wake-on-connect and idle suspend
```mermaid ```mermaid
@@ -155,8 +190,21 @@ sequenceDiagram
G-->>B: request held, then served G-->>B: request held, then served
``` ```
Suspend deletes only the pod. The PVC keeps conversations, sessions, and config, so resume Suspend deletes only the pod. The PVC keeps conversations, sessions, config **and the agent
reattaches exactly the same state. workspace**, so resume reattaches exactly the same state. Both trees live on one PVC: DSH state at
`/home/node/.dsh` (the volume root) and the workspace at `/workspace` (a second mount of the same
volume with `subPath: workspace`, which keeps `.credentials.yaml` out of the agent-visible tree).
The idle sweeper never touches the volume; deleting the claim still garbage-collects it, so
`POST /recreate` wipes state and workspace together.
**Workspace as home.** The template sets `HOME=/workspace`. DSH has no start-path setting for its
directory picker: the browse backend lists `$HOME` when the dialog opens, so with the image default
(`/home/node`, off the PVC) the picker would start on a throwaway directory. The override makes the
picker open on the persisted workspace and makes `~` resolve there; the cost is that tool caches
and config kept under `$HOME` (`npm`/`pnpm`, `uv`, `git`, `gh`, and DSH's global `~/.agents`
instructions and skills) become dotfiles in the workspace. `DSH_HOME` stays `/home/node/.dsh`, so
sessions, config and credentials remain outside the agent-visible tree. Pod env is stamped when a
sandbox is created, so an existing sandbox keeps the old `HOME` until it is recreated.
**Ownership boundary.** The gateway shares its namespace with claims created outside it — the **Ownership boundary.** The gateway shares its namespace with claims created outside it — the
example's resource-layer walkthrough applies `dsh-alice` by hand, and every claim is named example's resource-layer walkthrough applies `dsh-alice` by hand, and every claim is named
@@ -202,6 +250,14 @@ sequenceDiagram
end end
``` ```
An upgrade with a missing/expired session, or one that would exceed
`MAX_IN_FLIGHT_PER_USER`, is accepted and then closed: `4401` for authentication, `1011` for
the per-user socket cap. Messages received from DSH above `WS_MAX_MESSAGE_BYTES` (16 MiB) close
the connection instead of being buffered; browser-to-DSH frames are bounded by uvicorn's own
limit. Client-supplied `Forwarded`, `X-Forwarded-User`,
`X-Auth-Request-*` and `x-forwarded-*`/`x-auth-request-*` headers are stripped on the HTTP and
WebSocket paths; `UPSTREAM_HEADER_ALLOWLIST` replaces that denylist with an explicit allowlist.
## Decisions and trade-offs ## Decisions and trade-offs
### ADR-1: Starlette ASGI instead of a sync Flask proxy ### ADR-1: Starlette ASGI instead of a sync Flask proxy
@@ -261,14 +317,21 @@ sequenceDiagram
(`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not (`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not
for a product. for a product.
### ADR-6: Gateway cookie session instead of bearer tokens in the browser ### ADR-6: Opaque server-side session cookie instead of bearer tokens in the browser
- **Decision:** browsers get `dsh_gw_session=<user>:<token>`, `HttpOnly`, `SameSite=Lax` - **Decision:** browsers get `dsh_gw_session=<sid>`, `HttpOnly`, `SameSite=Lax` (`Secure` when
(`Secure` when enabled); API clients may still use `Authorization: Bearer`. enabled); the sid maps to `Session(user, expires_at)` in gateway memory. API clients may still
- **Why:** tokens never appear in URLs or JS-readable storage, and `SameSite=Lax` plus a strict use `Authorization: Bearer`. The cookie name is `__Host-dsh_gw_session` when the Secure flag
`Origin`/`Referer` check on cookie-authenticated mutations blocks basic CSRF. is on and no explicit `COOKIE_NAME` is set.
- **Cost:** cookie-based auth needs TLS and correct `COOKIE_SECURE`; there is no signed-cookie - **Why:** tokens never appear in URLs or JS-readable storage, the cookie value is useless if
layer, so the gateway trusts the token hash comparison for validation. leaked outside the gateway session table, sessions can be revoked server-side (logout,
user delete, recreate, expiry), and a cookie cannot be reused as a bearer token for another
user's path. `SameSite=Lax` plus a strict `Origin`/`Referer` check on cookie-authenticated
mutations blocks basic CSRF.
- **Cost:** stateful auth in a single process: a restart logs everyone out and horizontal
scaling needs a shared session store. Cookie-based auth also needs TLS and the correct
`COOKIE_SECURE` setting; the gateway refuses to start with `COOKIE_SECURE=false` unless
`ALLOW_INSECURE_COOKIES=1` is set for a local HTTP demo.
## Security and RBAC ## Security and RBAC
@@ -287,26 +350,52 @@ Notes and limits:
permission is scoped to one namespace, but anyone who can read gateway memory or logs can permission is scoped to one namespace, but anyone who can read gateway memory or logs can
impersonate users. Prefer the upstream token API when available. impersonate users. Prefer the upstream token API when available.
- **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`. - **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`.
- **Browser-facing hardening:** `HttpOnly`/`SameSite=Lax`/optional `Secure` cookies, (It does read the optional signup invite code if it is mounted as an env var.)
timing-safe SHA-256 token comparison, strict same-origin check on cookie-auth mutations, - **Browser-facing hardening:** opaque server-side sessions instead of token-bearing cookies,
request-body limit (`MAX_BODY_MB`), hop-by-hop header stripping, upstream `Set-Cookie` `HttpOnly`/`SameSite=Lax`/optional `Secure` (+ `__Host-` prefix when secure),
dropped, DSH `Location` headers rewritten under `/u/<user>/`. timing-safe SHA-256 bearer-token comparison, timing-safe invite-code comparison, strict
same-origin check on cookie-auth mutations, request-body limit (`MAX_BODY_MB`), hop-by-hop
header stripping, client identity/hop headers (`Forwarded`, `X-Forwarded-User`,
`X-Auth-Request-*`, `x-forwarded-*`/`x-auth-request-*`) stripped on HTTP and WebSocket,
upstream `Set-Cookie` dropped, DSH `Location` headers rewritten under `/u/<user>/`.
- **Session lifecycle:** login/signup mint an opaque sid; logout and user delete revoke it;
`/recreate` revokes every session for that user; expiry (`COOKIE_MAX_AGE`, default 7 days) is
enforced on lookup. The table is in memory, so a restart logs browsers out and a second
replica needs shared state.
- **Abuse controls (per replica):** `AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW` throttle
unauthenticated signup/login per client with `429` + `Retry-After`; `SIGNUP_INVITE_CODE`
gates signup; `MAX_IN_FLIGHT_PER_USER` caps concurrent proxied requests + WebSockets per user;
`WS_MAX_MESSAGE_BYTES` caps messages received from DSH; browser-to-DSH frames are bounded by
uvicorn. These are in-process guards, not an ingress rate limiter or audit log.
- **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's - **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's
`Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are `Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are
always trusted. always trusted.
- **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set - **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set
`COOKIE_SECURE=true`. Without TLS, session cookies are replayable on the network. `COOKIE_SECURE=true`. The base demo sets `COOKIE_SECURE=false` with
`ALLOW_INSECURE_COOKIES=1` for the port-forward walkthrough; without TLS, session cookies are
replayable on the network and that combination must never be exposed.
- **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary, - **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary,
and the shared provider keys (`dsh-provider-keys` plus the optional and the shared provider keys (`dsh-provider-keys` plus the optional
`dsh-provider-keys-extra`). `manifests/optional/network-policy/` adds an opt-in sandbox `dsh-provider-keys-extra`). The base example is demo-only: production requires gVisor for the
NetworkPolicy, and gVisor/`nodeSelector` are opt-in PodSpec fields. Add per-tenant sandboxes, the sandbox NetworkPolicy, the gateway egress policy and the exposure NetworkPolicy,
credentials before calling it multi-tenant-grade. plus per-tenant credentials or an egress proxy. See the
- **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies [production checklist](optional-hardening.md#6-production-checklist).
and extra provider keys are documented in [optional hardening](optional-hardening.md). The - **DSH's inner file sandbox is disabled by deployment override.** The template sets
base manifests stay portable (no gVisor RuntimeClass, no active node labels, no `DSH_PERMISSION_MODE=danger-full-access` because the in-container backends are unusable here:
NetworkPolicy and no extra Secret required). `bubblewrap` ships in the image but is unsetuid and cannot create an unprivileged user namespace
- **No gateway rate limiting.** Signup can create claims until the cluster or warm pool is under the pod's seccomp/capability surface, and no Landlock runner is available; the confined
exhausted; put limits at the ingress for real deployments. executor would otherwise fail closed on every command. The pod remains the boundary, so an
agent can read its own `DSH_HOME`. Hosts with a working backend can keep a confined mode;
multi-tenant deployments must put the pod in gVisor and a NetworkPolicy.
- **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies,
extra provider keys and failed-Pod recovery are documented in
[optional hardening](optional-hardening.md). The base manifests stay portable (no gVisor
RuntimeClass, no active node labels, no NetworkPolicy, no extra Secret required, and the
controller's default `failedPodPolicy: Retain`).
- **SSO is trust-on-hop.** `AUTH_PROXY_HEADER` makes the gateway trust the proxy-asserted
identity; the SSO variant must remain the only path to the Service, and its NetworkPolicy is
required, not optional. Run [`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) after
applying it.
## Failure modes ## Failure modes
@@ -315,7 +404,15 @@ Notes and limits:
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted | | DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) | | Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool | | Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
| Gateway restart | Idle sandboxes stop suspending; handshake cache lost | Rebuilt lazily on next request; consider a shared store for production | | Gateway restart | Idle sandboxes stop suspending; handshake cache and browser sessions lost | Idle clocks and handshake cache rebuild lazily; sessions do not, so browsers must sign in again after a rollout. Use a shared session store for production |
| Gateway configured `COOKIE_SECURE=false` without `ALLOW_INSECURE_COOKIES=1` | Gateway pod exits at startup / `CrashLoopBackOff` | Intentional guard against an insecure cookie on an HTTPS deployment; set `ALLOW_INSECURE_COOKIES=1` only for the local plain-HTTP demo, or set `COOKIE_SECURE=true` |
| Auth rate limit hit | `429` + `Retry-After` on signup/login | Per-client in-memory bucket (`AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW`); wait for the window or tune it. Behind a proxy, keying uses the rightmost `X-Forwarded-For` entry |
| Signup invite missing/wrong | `403` before the claim is created | `SIGNUP_INVITE_CODE` is set; send the `invite` form field or `X-Signup-Invite` header |
| Per-user concurrency cap hit | HTTP `503` + `Retry-After`, or WebSocket close `1011` | `MAX_IN_FLIGHT_PER_USER` protects the single gateway from one busy tenant; reduce concurrency or raise the cap deliberately. These are per replica |
| Oversized WebSocket message from DSH | Connection closes | `WS_MAX_MESSAGE_BYTES` (16 MiB) is enforced on upstream messages; raise it only if DSH legitimately needs larger frames |
| Session expiry / revocation | `401` (HTTP) or close `4401` (WebSocket) | Expected after logout, user delete, `/recreate`, `COOKIE_MAX_AGE` expiry or a gateway restart; sign in again |
| SSO variant applied without its NetworkPolicy (or with an Ingress) | Gateway accepts spoofed `X-Forwarded-User` | Demo-only configuration; keep `75-networkpolicies.yaml`, remove the second path, and run `scripts/preflight-sso.sh` |
| Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. |
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once | | DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes | | Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
| Two gateway replicas | Double suspend/wake races | Keep `replicas: 1` until state is externalized | | Two gateway replicas | Double suspend/wake races | Keep `replicas: 1` until state is externalized |
@@ -323,6 +420,7 @@ Notes and limits:
| gVisor + `kubectl port-forward` | Forward refuses or returns nothing | Port-forward is incompatible with gVisor; use the gateway or a reachable Service | | gVisor + `kubectl port-forward` | Forward refuses or returns nothing | Port-forward is incompatible with gVisor; use the gateway or a reachable Service |
| Provider key added but missing in the pod | Requests still fail with `MISSING_CREDENTIAL` | Pod env is stamped at creation; recreate spares/pods. A stored credential wins over env | | Provider key added but missing in the pod | Requests still fail with `MISSING_CREDENTIAL` | Pod env is stamped at creation; recreate spares/pods. A stored credential wins over env |
| `nodeSelector` matches no node | Pod `Pending`, `0/N nodes are available` | Fix the label, pool capacity or taint/toleration; RuntimeClass scheduling may also constrain it | | `nodeSelector` matches no node | Pod `Pending`, `0/N nodes are available` | Fix the label, pool capacity or taint/toleration; RuntimeClass scheduling may also constrain it |
| Sandbox Pod reaches phase `Failed` (node eviction, spot reclaim) | Sandbox reports `Finished=True`; the gateway keeps failing against a dead Pod | Set `failedPodPolicy: Recreate` (agent-sandbox v1.0.8+) so the controller replaces the Pod with the same identity and PVCs; otherwise delete the Pod by hand. See the hardening doc |
## Upstream feature request: remove log scraping ## Upstream feature request: remove log scraping
@@ -345,10 +443,11 @@ single-retry refresh are the supported workaround.
## Evolution path ## Evolution path
1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission. 1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission.
2. Move idle clocks, in-flight tracking, and handshake cache to a shared store; then scale the 2. Move idle clocks, in-flight tracking, handshake cache **and sessions** to a shared store; then
gateway horizontally (sticky routing not required if state is shared). scale the gateway horizontally (sticky routing not required if state is shared).
3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes. 3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes.
4. Add NetworkPolicies (gateway → sandboxes only), TLS everywhere, rate limits, and audit 4. Add NetworkPolicies (gateway → sandboxes only, plus the documented gateway egress policy),
logging at the ingress. gVisor for sandbox pods, TLS everywhere, ingress-level rate limits/quota and audit logging.
The gateway's in-process limits are not a substitute.
5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to 5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to
per-user subdomains with wildcard DNS/TLS. per-user subdomains with wildcard DNS/TLS.

View File

@@ -1,18 +1,31 @@
# Optional hardening: gVisor, nodeSelector, NetworkPolicy and provider keys # Optional hardening: gVisor, nodeSelector, NetworkPolicy, provider keys and failed-Pod recovery
The base example is deliberately portable: it runs on kind and GKE without a The base example is deliberately portable: it runs on kind and GKE without a
gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it
boots with only the required `dsh-provider-keys` Secret. This document covers boots with only the required `dsh-provider-keys` Secret. That portability is
four **opt-in** layers: exactly why the base manifests are **demo-only**. This document covers the
controls you add for production and the steps to enable or roll them back.
| Layer | Where it lives | What it changes | **Production is not the base walkthrough.** At minimum, a production deployment
needs: gVisor for the sandbox pods (or another kernel-isolation boundary), the
sandbox NetworkPolicy, the gateway egress policy, the exposure variant's
NetworkPolicy, TLS with `COOKIE_SECURE=true` (and no
`ALLOW_INSECURE_COOKIES`), SSO (or an equivalent authenticating proxy) as the
only path to the gateway, and per-tenant provider credentials/egress. The
checklist at the end summarizes this; without the network and kernel layers the
example is not tenant-isolated.
This document covers four **opt-in** controls plus failed-Pod recovery:
| Setting | Where it lives | What it changes |
| --- | --- | --- | | --- | --- | --- |
| gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel | | gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel; required for untrusted agents in production |
| nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool | | nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool |
| NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/` | limits sandbox, gateway and proxy connectivity | | NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/`, plus the gateway egress recipe | limits sandbox, gateway and proxy connectivity; the sandbox and gateway-egress policies are required for production |
| Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox | | Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox |
| Failed-Pod recovery | commented `failedPodPolicy` in the SandboxTemplates | replaces a Failed sandbox Pod instead of parking the sandbox |
Each layer is independent. Enable only what the cluster supports and test the Each setting is independent. Enable only what the cluster supports and test the
walkthrough after each change. walkthrough after each change.
## 1. gVisor ## 1. gVisor
@@ -51,7 +64,7 @@ PodSpec; no CRD change is involved.
- [`manifests/50-gateway.yaml`](../manifests/50-gateway.yaml) - [`manifests/50-gateway.yaml`](../manifests/50-gateway.yaml)
- [`manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml`](../manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml) - [`manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml`](../manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml)
(only with the SSO variant) (only with the SSO variant)
- [minimal sandbox-template.yaml](../../minimal-single-tenant/manifests/sandbox-template.yaml) - [minimal template](../../minimal-single-tenant/manifests/10-sandboxtemplate.yaml)
**Existing install:** a template edit does not update existing Sandboxes, and **Existing install:** a template edit does not update existing Sandboxes, and
warm-pool spares use `OnReplenish`, so patch explicitly. Plan a maintenance warm-pool spares use `OnReplenish`, so patch explicitly. Plan a maintenance
@@ -233,10 +246,17 @@ kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
The base example does **not** manage NetworkPolicy: the SandboxTemplate is The base example does **not** manage NetworkPolicy: the SandboxTemplate is
`networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps `networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps
the walkthrough portable and independent of the sandbox-router; the opt-in the walkthrough portable and independent of the sandbox-router. **For
policies below are standard Kubernetes `NetworkPolicy` objects. production this is not optional**: without the sandbox policy every tenant can
reach every other tenant and cluster-internal endpoints, and without a gateway
egress policy the gateway can talk to anything the cluster network allows.
Apply both. The policies below are standard Kubernetes `NetworkPolicy` objects.
### Optional sandbox policy > **Demo vs production.** An installation that skips the sandbox NetworkPolicy
> and the gateway egress policy is a demo. Exposure (Ingress/Gateway API) adds
> ingress protection only; it does not isolate tenants from each other.
### Sandbox policy (required for production)
[`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml) [`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml)
selects every Sandbox pod through the agent-sandbox tracking label selects every Sandbox pod through the agent-sandbox tracking label
@@ -300,10 +320,10 @@ Caveats:
- `kubectl port-forward` still works for debugging (the connection is local to - `kubectl port-forward` still works for debugging (the connection is local to
the pod), except under gVisor. the pod), except under gVisor.
### Exposure-variant policies ### Exposure-variant policies (required, part of the variant)
Each exposure variant ships its own policy. They are part of the variant, not a Each exposure variant ships its own policy. They are part of the variant and
separate opt-in step: **required for production**; an exposure without its policy is demo-only:
- [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml) - [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml)
allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the
@@ -316,7 +336,10 @@ separate opt-in step:
The SSO policy is what turns the trust boundary in the The SSO policy is what turns the trust boundary in the
[exposure README](../manifests/exposure/README.md) into network enforcement. [exposure README](../manifests/exposure/README.md) into network enforcement.
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
variants. variants. After applying the SSO variant, run
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh): it checks the
gateway's SSO env, the Secrets, the TLS reference, leftover placeholders and
accidental second paths (such as an Ingress).
Verify the negative case from a sandbox pod (it is not the proxy): Verify the negative case from a sandbox pod (it is not the proxy):
@@ -331,11 +354,12 @@ different pod label, replace the `podSelector` with the correct selector or an
`ipBlock`. As with the sandbox policy, kubelet probes can be affected on some `ipBlock`. As with the sandbox policy, kubelet probes can be affected on some
CNIs. CNIs.
### Gateway egress (documented, not shipped) ### Gateway egress (required for production)
The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. The The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. An
API server endpoint is cluster-specific (Service CIDR or node IP/port), so an egress policy is **required for production** but is not shipped, because the API
egress policy is **not** shipped. If your cluster has a stable API-server CIDR server endpoint is cluster-specific (Service CIDR or node IP/port) and a wrong
value silently breaks signup/proxy. If your cluster has a stable API-server CIDR
and port, add an egress policy like this (replace the placeholders; verify the and port, add an egress policy like this (replace the placeholders; verify the
port, commonly `443` or `6443`): port, commonly `443` or `6443`):
@@ -412,7 +436,7 @@ Secrets; when the same key exists twice, the later `envFrom` entry wins.
### Supported names (curated) ### Supported names (curated)
These are the names DSH v0.2.1-rc.3 resolves through its packaged provider These are the names the pinned DSH release resolves through its packaged provider
catalog; the catalog has more entries, these are the common ones: catalog; the catalog has more entries, these are the common ones:
| Provider | Environment variable | | Provider | Environment variable |
@@ -473,14 +497,194 @@ kubectl -n dsh-demo exec "$POD" -- sh -c '
- The optional Secret is created imperatively; the repository never contains a - The optional Secret is created imperatively; the repository never contains a
real key. Keep it out of Git and out of shell history where possible. real key. Keep it out of Git and out of shell history where possible.
## 5. Verification summary ## 5. Failed-Pod recovery
### What it changes
When a sandbox Pod reaches phase `Failed`, the controller's default policy,
`Retain`, leaves the Pod in place and surfaces `Finished=True` on the Sandbox.
The Sandbox, its claim and its PVC survive, but nothing runs: the gateway keeps
proxying to a dead Pod and every request fails until someone deletes the Pod or
the claim.
agent-sandbox v1.0.8+ (the current pin in [versions.env](../../../versions.env) is newer) adds
`spec.failedPodPolicy` to `Sandbox` and to `SandboxTemplate`:
| Value | Behavior |
| --- | --- |
| `Retain` (default) | The Failed Pod stays and the Sandbox reports `Finished=True`; the evidence stays available for debugging. |
| `Recreate` | The controller deletes the Failed Pod and starts a replacement; the Sandbox identity and its Sandbox-owned PVCs are retained. |
Repeated failures back off like a Deployment - 5 s, doubling to 5 min between
creations - so a crash loop cannot hammer the API server. The backoff resets
once the replacement Pod has been continuously `Running` for 10 minutes, and it
lives in memory only, so a controller restart clears it.
The base templates ship it commented out, for two reasons:
- A deterministic failure (bad image, bad Secret, a wrong probe) would recreate
forever while hiding the cause.
- The templates use `restartPolicy: OnFailure`, so the kubelet restarts a
crashing container in place and the Pod does not reach phase `Failed` anyway.
This policy matters for terminal failures - most commonly node eviction
(`Evicted`), when a spot/preemptible node is reclaimed or the node hits memory
pressure.
No gateway change is needed: the handshake cache is keyed by Pod UID, so a
replacement Pod re-handshakes on the next request, and the existing `401` retry
covers containers restarted in place.
### Enable it
```bash
# 1. The blueprint for future sandboxes.
kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
-p '{"spec":{"failedPodPolicy":"Recreate"}}'
# 2. Warm-pool spares were stamped at creation: delete them and let the pool
# replenish from the updated template.
kubectl -n dsh-demo delete sandboxes -l agents.x-k8s.io/warm-pool-sandbox
# 3. Claimed sandboxes keep their own spec: patch them in place.
for sb in $(kubectl -n dsh-demo get sandboxes \
-l '!agents.x-k8s.io/warm-pool-sandbox' -o jsonpath='{.items[*].metadata.name}'); do
kubectl -n dsh-demo patch sandbox "$sb" --type merge \
-p '{"spec":{"failedPodPolicy":"Recreate"}}'
done
```
For a fresh install, uncomment `failedPodPolicy: Recreate` in the template
instead.
### Verify
```bash
SB=$(kubectl -n dsh-demo get sandboxclaim dsh-alice -o jsonpath='{.status.sandbox.name}')
kubectl -n dsh-demo get sandbox "$SB" -o jsonpath='{.spec.failedPodPolicy}{"\n"}'
# Recreate
```
A failure drill that never touches a user sandbox - a `Sandbox` with
`restartPolicy: Never` and a container that exits non-zero (swap the image if
Docker Hub is unreachable):
```bash
kubectl -n dsh-demo apply -f - <<'EOF'
apiVersion: agents.x-k8s.io/v1beta1
kind: Sandbox
metadata:
name: failed-pod-drill
spec:
failedPodPolicy: Recreate
podTemplate:
spec:
restartPolicy: Never
containers:
- name: drill
image: busybox:1.36
command: ["sh", "-c", "exit 1"]
EOF
kubectl -n dsh-demo get pods -w
# The drill Pod reaches phase Failed; a replacement appears within ~5 s, then
# 10 s, 20 s, ... as the container keeps exiting non-zero.
kubectl -n dsh-demo delete sandbox failed-pod-drill
```
Repeat the drill with `failedPodPolicy: Retain` (or omit the field) to see the
default: the Pod stays `Failed` and the Sandbox condition `Finished` becomes
`True`.
### Caveats
- `Recreate` deletes the Failed Pod, so its logs go with it. Capture
`kubectl get events` or switch back to `Retain` when debugging.
- The replacement is a cold start: DSH boots again and issues a new launch
token. The workspace comes back from the PVC; in-flight sessions do not.
- The field needs agent-sandbox v1.0.8+. A v1.0.7 cluster rejects it as an
unknown field.
- It applies to sandbox Pods only. The gateway and oauth2-proxy are plain
Deployments, and their ReplicaSets already replace Failed Pods.
### Roll back
```bash
kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
-p '{"spec":{"failedPodPolicy":"Retain"}}'
for sb in $(kubectl -n dsh-demo get sandboxes -o jsonpath='{.items[*].metadata.name}'); do
kubectl -n dsh-demo patch sandbox "$sb" --type merge \
-p '{"spec":{"failedPodPolicy":"Retain"}}'
done
```
Warm-pool spares pick the default up as they are claimed and replenished, or
delete them as in step 2 above.
## 6. Production checklist
The base walkthrough is a demo. A production install must satisfy every item
below; the sections above give the commands and caveats.
**Isolation and network**
- [ ] Sandbox pods run under gVisor (or an equivalent kernel-isolation
boundary) because DSH runs with `DSH_PERMISSION_MODE=danger-full-access`
in this image; the pod is the boundary. (Section 1.)
- [ ] The sandbox NetworkPolicy is applied and verified: no tenant→tenant,
no tenant→cluster-Service/API, DNS + public HTTPS only. (Section 3.)
- [ ] The gateway egress policy is applied with the cluster's real API-server
CIDR/port, scoped to DNS, the API server and sandbox pods on `3080`.
(Section 3, "Gateway egress".)
- [ ] The exposure variant's NetworkPolicy is applied. An exposure without it is
demo-only. For SSO, the gateway is reachable **only** through
oauth2-proxy; there is no second Ingress/Gateway/port-forward path.
- [ ] Nodes/CNI enforce NetworkPolicy (verify the negative cases, not just the
positive path).
- [ ] The gateway and (optionally) oauth2-proxy run with `runAsNonRoot`,
dropped capabilities, `RuntimeDefault` seccomp and a read-only root
filesystem, as shipped.
**Authentication, TLS and abuse controls**
- [ ] TLS terminates in front of the gateway, `COOKIE_SECURE=true`, and
`ALLOW_INSECURE_COOKIES` is **not** set. The gateway refuses to start with
an insecure cookie unless explicitly acknowledged.
- [ ] SSO (or an equivalent authenticating proxy) is enabled with
`AUTH_PROXY_HEADER=X-Forwarded-User`, and
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) exits `0`.
- [ ] No `your-github-org` / `dsh-demo.example.com` placeholder remains.
- [ ] `SIGNUP_INVITE_CODE` is set from a Secret if the gateway's own signup form
is reachable, and `AUTH_RATE_LIMIT` is set deliberately (`30`/60s shipped).
Add ingress-level rate limiting/quota too; the gateway's buckets are
in-process and reset on restart.
- [ ] Session lifetime (`COOKIE_MAX_AGE`) and the logout/delete revocation
behavior are acceptable; account for gateway restarts logging users out.
- [ ] `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` fit the workload.
**Credentials, platform and operations**
- [ ] Provider keys are per-tenant (or routed through an egress proxy) instead
of the shared `dsh-provider-keys` Secrets; any shared key is treated as
visible to every tenant.
- [ ] RBAC stays namespace-scoped; `pods/log` is treated as sensitive because
it contains launch tokens and model output. Plan the upstream token API
migration.
- [ ] Images are digest-pinned and dependency installs are hash-locked.
- [ ] Gateway replicas stay at `1` (in-memory sessions/idle state) or the state
is externalized.
- [ ] `failedPodPolicy: Recreate` is set if node eviction/spot reclaim is
expected.
- [ ] Audit logging, alerting and backups of the PVCs are in place.
## 7. Verification summary
Required locally for any change to this example: Required locally for any change to this example:
```bash ```bash
make check # versions, example layout, manifest YAML shape make check # versions, example layout, manifest YAML shape
yamllint . yamllint .
make test # gateway unit tests (gateway code is untouched) make test # only when the gateway code changed
shellcheck examples/dsh-agents-as-a-service/scripts/*.sh
``` ```
CI additionally parses every manifest and runs `kubectl apply CI additionally parses every manifest and runs `kubectl apply
@@ -494,19 +698,30 @@ Manual checks, on a cluster with the relevant CNI/runtime:
gateway proxy works, port-forward limitation acknowledged. gateway proxy works, port-forward limitation acknowledged.
- nodeSelector: pods land on the intended nodes; no `Pending` events. - nodeSelector: pods land on the intended nodes; no `Pending` events.
- Sandbox policy: the allow/deny matrix above. - Sandbox policy: the allow/deny matrix above.
- Gateway egress policy: signup and proxy still work; the gateway cannot reach
an arbitrary cluster Service or the metadata server.
- Exposure policies: browser path works; a non-proxy pod cannot reach the - Exposure policies: browser path works; a non-proxy pod cannot reach the
gateway; probes stay healthy. gateway; probes stay healthy. `scripts/preflight-sso.sh` exits `0`.
- Cookie/TLS guard: with `COOKIE_SECURE=false` and no
`ALLOW_INSECURE_COOKIES` the gateway refuses to start; with TLS it starts and
the cookie is `Secure` (`__Host-` prefixed).
- Auth abuse controls: `429` + `Retry-After` after the configured limit, `403`
on a wrong invite code, and revocations on logout/delete/recreate.
- Provider keys: set/unset output (never the values) and the refresh procedure. - Provider keys: set/unset output (never the values) and the refresh procedure.
- Failed-Pod recovery: `spec.failedPodPolicy` on the Sandbox, and the drill
above replaces a `Failed` Pod while keeping the claim and PVC.
## 6. Rollback summary ## 8. Rollback summary
| Layer | Rollback | | Layer | Rollback |
| --- | --- | | --- | --- |
| Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` | | Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` |
| Gateway egress policy | `kubectl delete networkpolicy dsh-gateway-egress -n dsh-demo` (do not leave it half-configured) |
| Exposure policy | delete the exposure variant directory | | Exposure policy | delete the exposure variant directory |
| gVisor | patch `runtimeClassName: null`, recreate pods | | gVisor | patch `runtimeClassName: null`, recreate pods |
| nodeSelector | patch `nodeSelector: null`, recreate pods | | nodeSelector | patch `nodeSelector: null`, recreate pods |
| Provider keys | delete the extra Secret or a key, recreate pods | | Provider keys | delete the extra Secret or a key, recreate pods |
| Failed-Pod recovery | patch `failedPodPolicy: Retain`, delete warm-pool spares |
No PVC or workspace data is touched by any rollback. Deleting a `SandboxClaim` No PVC or workspace data is touched by any rollback. Deleting a `SandboxClaim`
is different: it cascades to the Sandbox **and** its PVC, so do not use claim is different: it cascades to the Sandbox **and** its PVC, so do not use claim

View File

@@ -1,16 +1,73 @@
FROM python:3.12-slim # syntax=docker/dockerfile:1.7
#
# Example gateway image: the tenant-facing proxy in front of DSH sandboxes.
# The base image is digest-pinned in versions.env (GATEWAY_PYTHON_IMAGE) and
# scripts/check-versions.sh verifies the ARG default below.
#
# Two stages on that base:
# build installs the fully hash-pinned runtime dependencies into /venv as
# a dedicated non-root UID, so the runtime stage copies a venv that
# contains no root-owned files.
# runtime copies /venv and the app, runs as UID 10000 with HOME=/tmp and no
# writable location of its own: usable with readOnlyRootFilesystem.
ARG GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Read-only-rootfs friendly: no .pyc writes, no home directory writes. # ---------------------------------------------------------------------------
# Build stage: hash-pinned virtualenv owned by the runtime identity.
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS build
ARG APP_UID=10000
ARG APP_GID=10000
# No .pyc writes and no user cache dirs: the runtime image stays read-only.
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
HOME=/tmp HOME=/tmp
WORKDIR /app RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app \
&& python -m venv /venv \
&& chown -R "$APP_UID:$APP_GID" /venv
WORKDIR /build
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY gateway.py . # Install as the unprivileged user so every file in /venv is owned by UID 10000.
# --require-hashes makes pip reject any requirement without a matching hash:
# the dependency set in requirements.txt is fully pinned and verified. Use the
# venv's own pip: the system pip would user-install into $HOME instead.
USER $APP_UID:$APP_GID
RUN /venv/bin/pip install --no-cache-dir --require-hashes -r requirements.txt
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS runtime
ARG APP_UID=10000
ARG APP_GID=10000
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HOME=/tmp \
PATH="/venv/bin:$PATH"
RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app
WORKDIR /app
# Dependencies come from the build stage: same interpreter, no build tools left
# in the runtime image and nothing owned by root inside /venv.
COPY --from=build --chown=$APP_UID:$APP_GID /venv /venv
COPY --chown=$APP_UID:$APP_GID gateway.py .
EXPOSE 8080 EXPOSE 8080
# Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image. # Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image.
USER 10000 USER 10000

View File

@@ -10,11 +10,13 @@ It does four things:
(only its SHA-256 is stored as a claim annotation), reports derived state, (only its SHA-256 is stored as a claim annotation), reports derived state,
and deletes the claim (cascading the sandbox and PVC). and deletes the claim (cascading the sandbox and PVC).
2. **Browser sessions** — exchanges a user token for an HttpOnly session 2. **Browser sessions** — exchanges a user token for an HttpOnly session
cookie so the DSH UI works in a browser at `/u/<user>/`. Automation can cookie whose value is only an opaque, server-side session id (user and
still use `Authorization: Bearer <token>`. expiry stay in the gateway's memory) so the DSH UI works in a browser at
`/u/<user>/`. Automation can still use
`Authorization: Bearer <token>`, which never comes from the cookie.
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to 3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
the sandbox's DSH Web server on `:3080`, including DSH's own cookie the sandbox's DSH Web server on `:3080`, including DSH's own cookie
handshake. handshake. Client-supplied forwarding/identity headers are stripped.
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity 4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
header an authenticating reverse proxy (for example oauth2-proxy) forwards, header an authenticating reverse proxy (for example oauth2-proxy) forwards,
auto-provisions that user, and exposes `POST /recreate` for a clean restart. auto-provisions that user, and exposes `POST /recreate` for a clean restart.
@@ -22,7 +24,7 @@ It does four things:
The DSH version pin lives in [`versions.env`](../../../versions.env) The DSH version pin lives in [`versions.env`](../../../versions.env)
(`DSH_VERSION` plus the optional repository `RELEASE_REVISION`); the example (`DSH_VERSION` plus the optional repository `RELEASE_REVISION`); the example
manifests and published images use the release identity built from it (currently manifests and published images use the release identity built from it (currently
`dsh-v0.2.1-rc.3-r4`). DSH binds `:3080`, serves `/`, gates `/api` behind a `dsh-v0.2.1-rc.5-r6`). DSH binds `:3080`, gates the UI and `/api` behind a
`dsh-auth-*` cookie and requires a WebSocket at `/api/remote.mux`. `dsh-auth-*` cookie and requires a WebSocket at `/api/remote.mux`.
## Flow ## Flow
@@ -63,10 +65,10 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
| Method | Path | Auth | Purpose | | Method | Path | Auth | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. | | `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. |
| `GET` | `/` | none | Landing page with signup + token login forms. With SSO it auto-provisions the proxy-identified user and redirects to their workspace. | | `GET` | `/` | none | Landing page with signup + token login forms (plus an invite-code input when `SIGNUP_INVITE_CODE` is set). With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
| `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`. JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large. | | `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`; when `SIGNUP_INVITE_CODE` is set, an `invite` form field or `X-Signup-Invite` header must match it (`403` otherwise, checked before the user lookup). JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large, `429` when the auth rate limit is hit. |
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token, `404` for an unknown user. | | `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token **and** for an unknown user (no username enumeration). `429` when the auth rate limit is hit. Both auth routes reject a form post whose `Origin`/`Referer` contradicts `Host` (`403`) when the browser sent one. |
| `POST` | `/logout` | none | Clears the session cookie and `303`s to `/`. | | `POST` | `/logout` | none | Revokes the server-side session, clears the cookie and `303`s to `/`. |
| `POST` | `/recreate` | trusted proxy header | **SSO only.** Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and `303`s to `/u/<user>/`. `401` without a valid identity header; cookie-authenticated calls must be same-origin. | | `POST` | `/recreate` | trusted proxy header | **SSO only.** Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and `303`s to `/u/<user>/`. `401` without a valid identity header; cookie-authenticated calls must be same-origin. |
| `GET` | `/users/{user}` | bearer or cookie | Derived state: `Provisioning`, `Waking`, `Ready`, `Suspending`, `Suspended`. | | `GET` | `/users/{user}` | bearer or cookie | Derived state: `Provisioning`, `Waking`, `Ready`, `Suspending`, `Suspended`. |
| `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. | | `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. |
@@ -78,19 +80,49 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
* `Authorization: Bearer <token>` is checked with a timing-safe SHA-256 * `Authorization: Bearer <token>` is checked with a timing-safe SHA-256
comparison against the `aaas.example.com/token-sha256` claim annotation. comparison against the `aaas.example.com/token-sha256` claim annotation.
* The browser session cookie is * The browser session cookie is **opaque**: its value is a random
`dsh_gw_session=<user>:<token>` (HttpOnly, SameSite=Lax, Path=/, optional server-side session id; the user and expiry live in gateway memory
Secure). A session for user A never authorizes user B. (`GatewayState.sessions`). A cookie can never be edited into another user's
credentials, a session id is not accepted as a bearer token, and a bearer
token never authorizes a different user. The cookie name defaults to
`__Host-dsh_gw_session` when `COOKIE_SECURE=true` (HTTPS) and to
`dsh_gw_session` for plain HTTP; attributes are HttpOnly, SameSite=Lax,
Path=/ and (with `COOKIE_SECURE`) Secure.
* Sessions are revoked on `POST /logout`, on `DELETE /users/{user}`, on
`POST /recreate`, and when they expire (looked up on every request; the
sweeper also drops expired entries). `from_env()` refuses to start without
`COOKIE_SECURE=true` or the explicit `ALLOW_INSECURE_COOKIES=1` opt-out for
local HTTP.
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and * Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
cookie-authenticated **WebSocket upgrades** require the request `Origin` cookie-authenticated **WebSocket upgrades** require the request `Origin`
(or `Referer` fallback) to match the `Host` header. Missing or mismatched (or `Referer` fallback) to match the `Host` header. Missing or mismatched
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS). cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
Form signup/login additionally reject a cross-origin `Origin`/`Referer`
when the browser sent one, before the body is read.
* `POST /users` and `POST /login` are rate limited per client:
`AUTH_RATE_LIMIT` attempts per `AUTH_RATE_WINDOW` seconds (default 30/60,
`0` disables). The client key is the rightmost `X-Forwarded-For` entry when
present (the address the last trusted proxy saw, not a spoofable leftmost
value), else the socket peer. Over-limit requests get `429` +
`Retry-After` before the body is read.
* Proxy requests strip client-supplied forwarding/identity headers in both
HTTP and WebSocket paths: `Forwarded`, every `X-Forwarded-*`
(including `X-Forwarded-User`), every `X-Auth-Request-*`, and `X-Real-IP`.
The gateway sets its own `X-Forwarded-For/Host/Proto/Port` values from the
connection. `UPSTREAM_HEADER_ALLOWLIST` optionally narrows the forwarded
client headers further (comma-separated, case-insensitive); unset keeps the
denylist behavior, and the gateway's own values are always added.
* The gateway's bearer token, the browser `Cookie` header and any `?token=` * The gateway's bearer token, the browser `Cookie` header and any `?token=`
query parameter are never forwarded upstream. Only the cached `dsh-auth-*` query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
cookie is injected. cookie is injected.
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are * Upstream `Set-Cookie` headers are dropped. `Location` headers that are
absolute paths are rewritten under `/u/<user>/`, and any `token` query absolute paths are rewritten under `/u/<user>/`, and any `token` query
parameter is stripped. parameter is stripped.
* Concurrency is capped per user: `MAX_IN_FLIGHT_PER_USER` proxied requests
(default 32) before the gateway answers `503` + `Retry-After` (HTTP) or
closes the WebSocket with `1011`. Messages received from DSH are limited to
`WS_MAX_MESSAGE_BYTES` (default 16 MiB); browser-to-DSH frames are bounded by
uvicorn's own WebSocket size limit.
### SSO (opt-in) ### SSO (opt-in)
@@ -106,7 +138,8 @@ kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-Use
When set, the gateway treats a request carrying that header as authenticated as When set, the gateway treats a request carrying that header as authenticated as
that user, provided the value is a valid DNS-1123 label; the header is never that user, provided the value is a valid DNS-1123 label; the header is never
trusted when the variable is unset. The landing page then auto-provisions the trusted when the variable is unset. The landing page then auto-provisions the
user (creates their claim idempotently, mints a rotated session token) and user (creates their claim idempotently, rotates the claim token when a new
session is needed and mints an opaque session cookie) and
redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean
restart. The base manifests leave it unset on purpose; the restart. The base manifests leave it unset on purpose; the
[SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to [SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to
@@ -133,17 +166,30 @@ oauth2-proxy's egress to the gateway, DNS and public HTTPS.
| --- | --- | | --- | --- |
| Unknown user / claim | `404` | | Unknown user / claim | `404` |
| Missing or invalid credentials | `401` | | Missing or invalid credentials | `401` |
| Cross-origin cookie mutation | `403` | | Unknown user on `/login` | `401` (same as a bad token; no username enumeration) |
| Cross-origin cookie mutation / cross-origin form signup or login | `403` |
| Auth route over `AUTH_RATE_LIMIT` | `429` + `Retry-After`, before the body is read |
| Request body over `MAX_BODY_MB` | `413` | | Request body over `MAX_BODY_MB` | `413` |
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10` | | Client aborts mid-body, before the response starts | `499` (`CLIENT_CLOSED_REQUEST`). Nothing is forwarded to DSH and nothing can be delivered; it is not an error, so no traceback is logged. Covers the body-reading control routes (`POST /users`, `POST /login`) as well as the proxy. |
| Client aborts mid-stream (SSE, long response) | The response already started, so its upstream status stands (typically `200`); the stream simply ends. No traceback, and the in-flight slot is released as the relay unwinds. |
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10`. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
| Upstream connection/protocol failure | `502` | | Upstream connection/protocol failure | `502` |
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` | | WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
| WS per-user concurrency cap | accept, then close `1011` |
| WS upstream/handshake failure | accept, then close `1011` | | WS upstream/handshake failure | accept, then close `1011` |
| WS message from DSH over `WS_MAX_MESSAGE_BYTES` | the upstream/relay closes the connection (websockets' message-too-big path) |
A close code can only be delivered after the WebSocket upgrade is accepted; A close code can only be delivered after the WebSocket upgrade is accepted;
Starlette would turn an early close into an HTTP 403, which is why WS auth Starlette would turn an early close into an HTTP 403, which is why WS auth
failures are `101`, then close `4401`. failures are `101`, then close `4401`.
An aborted request is deliberately silent: uvicorn runs with `--no-access-log`,
and the `499` cannot reach a peer that has already left (uvicorn's `send()` is
a no-op once the socket is closed). Sandbox resolution, the wake and the DSH
handshake run before the body is read, so an aborted upload can cost a wake or
a handshake but never reaches DSH.
## Configuration ## Configuration
All values are read from the environment by `Settings.from_env()`; every one All values are read from the environment by `Settings.from_env()`; every one
@@ -158,21 +204,35 @@ can also be injected into `create_app` for tests.
| `DSH_PORT` | `3080` | Upstream DSH port. | | `DSH_PORT` | `3080` | Upstream DSH port. |
| `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. | | `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. |
| `DSH_LOG_TAIL_LINES` | `2000` | How many log lines to scan for the launch token. | | `DSH_LOG_TAIL_LINES` | `2000` | How many log lines to scan for the launch token. |
| `COOKIE_NAME` | `dsh_gw_session` | Browser session cookie name. | | `COOKIE_NAME` | `__Host-dsh_gw_session` when secure, else `dsh_gw_session` | Browser session cookie name. Only an opaque session id is stored in it. |
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie when served over TLS. | | `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie. Required unless `ALLOW_INSECURE_COOKIES=1` is set for local HTTP. |
| `COOKIE_MAX_AGE` | `604800` | Session cookie lifetime in seconds. | | `ALLOW_INSECURE_COOKIES` | `0` | Explicit opt-out for plain-HTTP demos. Without it, `Settings.from_env()` refuses to start unless `COOKIE_SECURE=true`. |
| `COOKIE_MAX_AGE` | `604800` | Session lifetime in seconds (server-side expiry and cookie `Max-Age`). |
| `MAX_BODY_MB` | `32` | Maximum buffered request body (and `413` threshold). | | `MAX_BODY_MB` | `32` | Maximum buffered request body (and `413` threshold). |
| `HTTP_TIMEOUT` | `300` | Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have **no read timeout**, so an idle event stream is not cut every five minutes. | | `HTTP_TIMEOUT` | `300` | Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have **no read timeout**, so an idle event stream is not cut every five minutes. |
| `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. | | `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. Sweeps also prune expired sessions and rate buckets and orphaned per-user state. |
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the matching `invite` form field or `X-Signup-Invite` header (timing-safe compare); the landing page then shows an invite input. Unset leaves signup open. |
| `AUTH_RATE_LIMIT` | `30` | Attempts allowed per client per `AUTH_RATE_WINDOW` on `POST /users` and `POST /login`; `0` disables the limit. |
| `AUTH_RATE_WINDOW` | `60` | Rate-limit window in seconds. |
| `UPSTREAM_HEADER_ALLOWLIST` | unset | Comma-separated, case-insensitive allowlist of client headers to forward upstream. Unset keeps the denylist behavior; forwarding/identity headers are stripped in both modes. |
| `MAX_IN_FLIGHT_PER_USER` | `32` | Concurrent proxied requests per user before `503`/WS `1011`. `0` disables the cap. |
| `WS_MAX_MESSAGE_BYTES` | `16777216` | Largest WebSocket message (16 MiB) accepted from DSH. Browser-to-DSH frames are bounded by uvicorn's own limit. |
| `AUTH_PROXY_HEADER` | unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. `X-Forwarded-User`). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see [SSO](#sso-opt-in)). | | `AUTH_PROXY_HEADER` | unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. `X-Forwarded-User`). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see [SSO](#sso-opt-in)). |
The shipped `manifests/50-gateway.yaml` sets `IDLE_TIMEOUT=360` and
`WAKE_TIMEOUT=240` for the demo; the e2e script overrides them to 60/120 so the
smoke test does not wait minutes for the sweeper. It also sets
`COOKIE_SECURE=false` together with the explicit `ALLOW_INSECURE_COOKIES=1`
acknowledgement because the demo is reached over plain HTTP (a `Secure` cookie
would never be sent). HTTPS deployments must set `COOKIE_SECURE=true`.
### Required RBAC ### Required RBAC
The gateway's ServiceAccount needs, in its namespace: The gateway's ServiceAccount needs, in its namespace:
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete * `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
* `sandboxes` (`agents.x-k8s.io`): get, patch * `sandboxes` (`agents.x-k8s.io`): get, list, patch
* `pods`: get * `pods`: get, list
* `pods/log`: get * `pods/log`: get
## Running locally ## Running locally
@@ -185,8 +245,10 @@ python -m pip install -r requirements-dev.txt
python -m pytest -q python -m pytest -q
python -m ruff check . python -m ruff check .
# Uvicorn factory (loads in-cluster credentials only on the first claim call) # Uvicorn factory (loads in-cluster credentials only on the first claim call).
uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080 # from_env() insists on an explicit acknowledgement for plain HTTP; behind TLS
# set COOKIE_SECURE=true instead.
ALLOW_INSECURE_COOKIES=1 uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
``` ```
Container image, from the repository root: Container image, from the repository root:
@@ -214,18 +276,27 @@ uvicorn's access log so bearer tokens in URLs are not logged.
Log rotation, a very chatty container, or DSH changing its startup message Log rotation, a very chatty container, or DSH changing its startup message
breaks the handshake. Prefer a first-class token/credential API when DSH breaks the handshake. Prefer a first-class token/credential API when DSH
offers one. offers one.
* **Single replica, in-memory state.** The handshake cookie cache, idle * **Single replica, in-memory state.** The handshake cookie cache, the opaque
activity clocks and in-flight counters live in one process. Running more session table, idle activity clocks, in-flight counters and rate-limit
than one replica would need shared state (or sticky routing), otherwise each buckets live in one process. Running more than one replica would need shared
replica handshakes independently and the sweeper may race another replica. state (or sticky routing), otherwise each replica handshakes independently,
the sweeper may race another replica, and a session/rate budget is only seen
by the replica that created it.
* **Sessions die with the process.** An opaque session id is valid only in the
process that minted it and expires after `COOKIE_MAX_AGE`; a restart logs
every browser out (tokens are unaffected).
* **In-memory token cache has no TTL.** It is keyed by pod UID, so pod * **In-memory token cache has no TTL.** It is keyed by pod UID, so pod
restarts invalidate it naturally; otherwise entries survive until the pod or restarts invalidate it naturally; otherwise entries survive until the pod or
claim is deleted. claim is deleted.
* **No TLS termination.** Run behind an ingress/load balancer; set * **No TLS termination.** Run behind an ingress/load balancer and set
`COOKIE_SECURE=true` there. `COOKIE_SECURE=true`. `Settings.from_env()` refuses to start without it
unless `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges plain-HTTP
sessions.
* **Origin-based CSRF protection only.** Cookie-auth mutations require a * **Origin-based CSRF protection only.** Cookie-auth mutations require a
same-origin `Origin`/`Referer`; there is no per-form CSRF token, rate same-origin `Origin`/`Referer`, and form signup/login reject a contradicting
limiting or login throttling. origin when the browser sends one; there is no per-form CSRF token. Auth
routes have per-client rate limiting and login throttling, but the budget is
in-process (per replica) and keyed by the rightmost `X-Forwarded-For` value.
* **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not * **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not
streamed. Responses (including SSE) are streamed. streamed. Responses (including SSE) are streamed.
* **Upstream cookies are not forwarded.** DSH cannot rotate its own * **Upstream cookies are not forwarded.** DSH cannot rotate its own

View File

@@ -7,9 +7,12 @@ single teaching-friendly module with three jobs:
(only its SHA-256 is stored, as a claim annotation), report derived state, (only its SHA-256 is stored, as a claim annotation), report derived state,
and delete the claim (which cascades the sandbox and its PVC). and delete the claim (which cascades the sandbox and its PVC).
* **Browser sessions** -- exchange a user token for an HttpOnly session cookie * **Browser sessions** -- exchange a user token for an HttpOnly session cookie
so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser requests whose value is an opaque, server-side session id (the user and expiry live in
authenticate with that cookie; automation can keep using memory) so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser
``Authorization: Bearer <token>``. requests authenticate with that cookie; automation can keep using
``Authorization: Bearer <token>``. Sessions are revoked on logout, user
deletion, recreate and expiry, and a cookie is never accepted as a bearer
token (nor the other way around).
* **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the * **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the
sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the
gateway scrapes the launch token from the pod logs, performs the ``?token=`` gateway scrapes the launch token from the pod logs, performs the ``?token=``
@@ -38,6 +41,7 @@ import contextlib
import hashlib import hashlib
import html import html
import json import json
import math
import os import os
import re import re
import secrets import secrets
@@ -62,7 +66,7 @@ from kubernetes import config as k8s_config
from kubernetes.client.exceptions import ApiException from kubernetes.client.exceptions import ApiException
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starlette.requests import Request from starlette.requests import ClientDisconnect, Request
from starlette.responses import ( from starlette.responses import (
HTMLResponse, HTMLResponse,
JSONResponse, JSONResponse,
@@ -121,6 +125,19 @@ OWNED_REQUEST_HEADERS = HOP_BY_HOP | {
"x-real-ip", "x-real-ip",
} }
# Default session cookie names. ``__Host-`` is only legal over HTTPS and pins
# the cookie to Path=/ with no Domain attribute; the insecure default keeps the
# example usable over plain HTTP behind a port-forward.
DEFAULT_COOKIE_NAME = "dsh_gw_session"
SECURE_COOKIE_NAME = "__Host-dsh_gw_session"
# Header prefixes a client must never be able to smuggle upstream. The gateway
# derives its own X-Forwarded-* values from the connection; anything the browser
# sends with these names is attacker-controlled and is dropped in both the HTTP
# and WebSocket proxy paths.
FORWARDING_HEADER_PREFIXES = ("x-forwarded-", "x-auth-request-")
FORWARDING_HEADERS = frozenset({"forwarded"})
_DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)") _DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)")
@@ -144,6 +161,11 @@ class UpstreamError(Exception):
"""The upstream DSH connection failed.""" """The upstream DSH connection failed."""
# nginx's "client closed request": the peer went away before it could be
# answered, so no status is ever delivered.
CLIENT_CLOSED_REQUEST = 499
# --- Pure helpers (unit-testable without an app) -------------------------- # --- Pure helpers (unit-testable without an app) --------------------------
@@ -244,6 +266,33 @@ def same_origin(headers) -> bool:
return False return False
def strip_forwarding_header(name: str) -> bool:
"""True for client-supplied identity/forwarding headers we never forward.
``X-Forwarded-User`` and ``X-Auth-Request-*`` carry identities an
authenticating proxy or the gateway itself owns; ``Forwarded`` /
``X-Forwarded-*`` describe the hop chain. Forwarding any of them lets a
browser spoof its origin or a trusted SSO user upstream.
"""
lowered = name.lower()
return lowered in FORWARDING_HEADERS or lowered.startswith(FORWARDING_HEADER_PREFIXES)
def client_identity(headers, client) -> str:
"""The rate-limit bucket for a request.
The rightmost ``X-Forwarded-For`` entry is the address the last trusted
proxy saw (each proxy appends), so it cannot be spoofed by prepending
arbitrary values. Without the header fall back to the socket peer.
"""
forwarded = (headers.get("x-forwarded-for") or "").strip()
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[-1]
return getattr(client, "host", None) or "unknown"
def rewrite_location(location: str, user: str) -> str: def rewrite_location(location: str, user: str) -> str:
"""Prefix absolute-path redirects so the browser stays under ``/u/<user>/``.""" """Prefix absolute-path redirects so the browser stays under ``/u/<user>/``."""
if not location.startswith("/") or location.startswith("//"): if not location.startswith("/") or location.startswith("//"):
@@ -301,6 +350,14 @@ def _env_float(name: str, default: float) -> float:
return default return default
def _env_allowlist(name: str) -> frozenset[str] | None:
"""Parse a comma-separated header allowlist; unset means "denylist mode"."""
raw = os.environ.get(name)
if raw is None:
return None
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
# --- Settings -------------------------------------------------------------- # --- Settings --------------------------------------------------------------
@@ -320,7 +377,7 @@ class Settings:
dsh_port: int = 3080 dsh_port: int = 3080
dsh_container: str = "dsh-web" dsh_container: str = "dsh-web"
dsh_log_tail_lines: int = 2000 dsh_log_tail_lines: int = 2000
cookie_name: str = "dsh_gw_session" cookie_name: str = DEFAULT_COOKIE_NAME
cookie_secure: bool = False cookie_secure: bool = False
cookie_max_age: int = 7 * 24 * 60 * 60 cookie_max_age: int = 7 * 24 * 60 * 60
max_body_bytes: int = 32 * 1024 * 1024 max_body_bytes: int = 32 * 1024 * 1024
@@ -334,9 +391,36 @@ class Settings:
# valid user in this header are treated as authenticated (SSO); unset (None) # valid user in this header are treated as authenticated (SSO); unset (None)
# disables SSO entirely and the header is never trusted. # disables SSO entirely and the header is never trusted.
auth_proxy_header: str | None = None auth_proxy_header: str | None = None
# Abuse controls. AUTH_RATE_LIMIT applies to the unauthenticated auth routes
# (POST /users and POST /login) per client and window; 0 disables it.
auth_rate_limit: int = 30
auth_rate_window: int = 60
# When set, signup requires this invite code (form field ``invite`` or the
# ``X-Signup-Invite`` header). None keeps signup open, as in the demo.
signup_invite_code: str | None = None
# Optional allowlist of client request headers to forward upstream. None
# keeps the default denylist behavior; a set (possibly empty) forwards only
# the named headers (plus the gateway's own X-Forwarded-* values).
upstream_header_allowlist: frozenset[str] | None = None
# Concurrent proxied requests per user before 503/1011 is returned.
max_in_flight_per_user: int = 32
# Largest WebSocket message accepted from DSH, in bytes. Browser-to-DSH
# frames are bounded by the ASGI server's own WebSocket limit.
ws_max_message_bytes: int = 16 * 1024 * 1024
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:
cookie_secure = _env_bool("COOKIE_SECURE", False)
allow_insecure = _env_bool("ALLOW_INSECURE_COOKIES", False)
if not cookie_secure and not allow_insecure:
raise RuntimeError(
"refusing to start with an insecure session cookie: set "
"COOKIE_SECURE=true behind TLS, or ALLOW_INSECURE_COOKIES=1 "
"for a local plain-HTTP demo"
)
cookie_name = os.environ.get("COOKIE_NAME") or (
SECURE_COOKIE_NAME if cookie_secure else DEFAULT_COOKIE_NAME
)
return cls( return cls(
namespace=os.environ.get("NAMESPACE", "dsh-demo"), namespace=os.environ.get("NAMESPACE", "dsh-demo"),
pool=os.environ.get("POOL", "dsh-pool"), pool=os.environ.get("POOL", "dsh-pool"),
@@ -345,13 +429,19 @@ class Settings:
dsh_port=_env_int("DSH_PORT", 3080), dsh_port=_env_int("DSH_PORT", 3080),
dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"), dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"),
dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000), dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000),
cookie_name=os.environ.get("COOKIE_NAME", "dsh_gw_session"), cookie_name=cookie_name,
cookie_secure=_env_bool("COOKIE_SECURE", False), cookie_secure=cookie_secure,
cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60), cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60),
max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024, max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024,
http_timeout=_env_float("HTTP_TIMEOUT", 300.0), http_timeout=_env_float("HTTP_TIMEOUT", 300.0),
sweep_interval=_env_float("SWEEP_INTERVAL", 15.0), sweep_interval=_env_float("SWEEP_INTERVAL", 15.0),
auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None, auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None,
auth_rate_limit=_env_int("AUTH_RATE_LIMIT", 30),
auth_rate_window=_env_int("AUTH_RATE_WINDOW", 60),
signup_invite_code=os.environ.get("SIGNUP_INVITE_CODE") or None,
upstream_header_allowlist=_env_allowlist("UPSTREAM_HEADER_ALLOWLIST"),
max_in_flight_per_user=_env_int("MAX_IN_FLIGHT_PER_USER", 32),
ws_max_message_bytes=_env_int("WS_MAX_MESSAGE_BYTES", 16 * 1024 * 1024),
) )
@@ -541,6 +631,19 @@ class Kubernetes:
# --- Gateway --------------------------------------------------------------- # --- Gateway ---------------------------------------------------------------
@dataclass(frozen=True)
class Session:
"""A browser session stored server-side, keyed by an opaque random id.
The cookie only carries the id; the user and the expiry stay in memory, so
a cookie can never be edited into another user's credentials and a stolen
token is not recoverable from the browser.
"""
user: str
expires_at: float
@dataclass @dataclass
class GatewayState: class GatewayState:
"""In-memory, per-process state (single replica by design).""" """In-memory, per-process state (single replica by design)."""
@@ -550,6 +653,10 @@ class GatewayState:
maintenance: dict[str, bool] = field(default_factory=dict) maintenance: dict[str, bool] = field(default_factory=dict)
# (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..." # (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..."
handshake: dict[tuple[str, str, str], str] = field(default_factory=dict) handshake: dict[tuple[str, str, str], str] = field(default_factory=dict)
# Opaque session id -> server-side session (user + expiry).
sessions: dict[str, Session] = field(default_factory=dict)
# Auth rate limiting: client -> (window start, attempts in window).
auth_rate: dict[str, tuple[float, int]] = field(default_factory=dict)
lock: asyncio.Lock = field(default_factory=asyncio.Lock) lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -571,17 +678,40 @@ class Gateway:
# -- credentials ------------------------------------------------------- # -- credentials -------------------------------------------------------
def session_for(self, sid: str | None) -> Session | None:
"""Look up a live session, dropping it when it has expired."""
if not sid:
return None
session = self.state.sessions.get(sid)
if session is None:
return None
if session.expires_at <= time.time():
self.state.sessions.pop(sid, None)
return None
return session
def revoke_session(self, sid: str | None) -> None:
if sid:
self.state.sessions.pop(sid, None)
def revoke_sessions(self, user: str) -> None:
for sid in [key for key, session in self.state.sessions.items() if session.user == user]:
self.state.sessions.pop(sid, None)
def credentials(self, headers, cookies) -> tuple[str | None, str | None]: def credentials(self, headers, cookies) -> tuple[str | None, str | None]:
"""Return ``(token, cookie_user)``; bearer wins over the session cookie.""" """Return ``(token, session_user)``; bearer wins over the session cookie.
A bearer token is never resolved from a cookie and a session id is never
accepted as a bearer token. An unknown or expired session is treated as
anonymous (and the expired entry is dropped).
"""
auth = headers.get("authorization", "") auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "): if auth.lower().startswith("bearer "):
return auth[7:].strip(), None return auth[7:].strip(), None
raw = cookies.get(self.settings.cookie_name) session = self.session_for(cookies.get(self.settings.cookie_name))
if raw: if session is None:
user, sep, token = raw.partition(":")
if sep and user and token:
return token, user
return None, None return None, None
return None, session.user
def remote_user(self, headers) -> str | None: def remote_user(self, headers) -> str | None:
"""The user asserted by a trusted reverse proxy, when SSO is enabled. """The user asserted by a trusted reverse proxy, when SSO is enabled.
@@ -600,30 +730,36 @@ class Gateway:
self, self,
claim: dict, claim: dict,
token: str | None, token: str | None,
cookie_user: str | None, session_user: str | None,
user: str, user: str,
remote: str | None = None, remote: str | None = None,
) -> bool: ) -> bool:
if remote == user: if remote == user:
return True # identity asserted by the authenticating proxy return True # identity asserted by the authenticating proxy
if cookie_user is not None and cookie_user != user: if session_user is not None:
return False # a session for another user never authorizes this one return session_user == user # server-side session bound to this user
return token_matches(claim, token) return token_matches(claim, token)
async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None: async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None:
"""Provision ``remote`` idempotently and mint/refresh a session token. """Provision ``remote`` idempotently and mint/refresh a session.
The authenticating proxy has already verified ``remote``, so it is The authenticating proxy has already verified ``remote``, so it is
trusted. A brand-new user is provisioned (claim created, token minted); trusted. A brand-new user is provisioned (claim created, token minted);
an existing user whose session cookie is missing or no longer matches an existing user whose session cookie is missing, foreign or no longer
gets a rotated token so a fresh cookie can be issued. A still-valid matches gets a rotated token so a fresh cookie can be issued. A still-valid
session is reused untouched. Provisioning is non-blocking: the sandbox session for the same user is reused untouched. Provisioning is
is awaited by the proxy path (``_resolve_sandbox``), so the landing page non-blocking: the sandbox is awaited by the proxy path
can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``. (``_resolve_sandbox``), so the landing page can report "Provisioning"
instead of stalling for ``WAKE_TIMEOUT``.
""" """
claim = await self.k8s.get_claim(remote) claim = await self.k8s.get_claim(remote)
token, cookie_user = self.credentials(headers, cookies) sid = cookies.get(self.settings.cookie_name)
if claim is not None and cookie_user == remote and token_matches(claim, token): session = self.session_for(sid)
if session is not None:
if session.user != remote:
# A session for another identity never authorizes this user.
self.revoke_session(sid)
elif claim is not None:
return claim return claim
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
@@ -640,7 +776,7 @@ class Gateway:
else: else:
await self.k8s.patch_token(remote, token_hash) await self.k8s.patch_token(remote, token_hash)
await self._touch(remote) await self._touch(remote)
self.set_session(response, remote, token) self.set_session(response, remote)
return await self.k8s.get_claim(remote) if is_new else claim return await self.k8s.get_claim(remote) if is_new else claim
@staticmethod @staticmethod
@@ -651,16 +787,73 @@ class Gateway:
and not same_origin(headers) and not same_origin(headers)
) )
def set_session(self, response: Response, user: str, token: str) -> None: def set_session(self, response: Response, user: str) -> str:
"""Mint an opaque server-side session and set the browser cookie."""
sid = secrets.token_urlsafe(32)
self.state.sessions[sid] = Session(
user=user,
expires_at=time.time() + self.settings.cookie_max_age,
)
response.set_cookie( response.set_cookie(
self.settings.cookie_name, self.settings.cookie_name,
f"{user}:{token}", sid,
max_age=self.settings.cookie_max_age, max_age=self.settings.cookie_max_age,
path="/", path="/",
httponly=True, httponly=True,
samesite="lax", samesite="lax",
secure=self.settings.cookie_secure, secure=self.settings.cookie_secure,
) )
return sid
# -- abuse controls ----------------------------------------------------
def _rate_limit(self, request: Request) -> Response | None:
"""Return a 429 response when this client is over the auth budget."""
limit = self.settings.auth_rate_limit
if limit <= 0:
return None
window = max(1, self.settings.auth_rate_window)
key = client_identity(request.headers, request.client)
now = time.time()
bucket = self.state.auth_rate.get(key)
if bucket is None or now - bucket[0] >= window:
self.state.auth_rate[key] = (now, 1)
return None
start, count = bucket
if count >= limit:
retry_after = max(1, math.ceil(start + window - now))
return self._error(
429,
"too many authentication attempts, retry shortly",
{"Retry-After": str(retry_after)},
)
self.state.auth_rate[key] = (start, count + 1)
return None
def _invite_allowed(self, request: Request, payload: dict) -> bool:
"""Check the signup invite code, when one is configured."""
expected = self.settings.signup_invite_code
if expected is None:
return True
provided = payload.get("invite")
if not isinstance(provided, str) or not provided:
provided = request.headers.get("x-signup-invite", "")
if not isinstance(provided, str) or not provided:
return False
return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
def _form_cross_origin(self, request: Request) -> bool:
"""Reject a form signup/login whose Origin/Referer contradicts Host.
Only checked when the browser actually sent one: non-browser clients
(automation, tests) commonly omit both headers and must keep working.
"""
if not self._wants_form(request):
return False
headers = request.headers
if not (headers.get("origin") or headers.get("referer")):
return False
return not same_origin(headers)
# -- activity / maintenance ------------------------------------------- # -- activity / maintenance -------------------------------------------
@@ -669,12 +862,21 @@ class Gateway:
self.state.last_activity[user] = time.time() self.state.last_activity[user] = time.time()
async def _acquire(self, user: str) -> bool: async def _acquire(self, user: str) -> bool:
"""Reserve an in-flight slot, waiting out a concurrent suspend.""" """Reserve an in-flight slot, waiting out a concurrent suspend.
Returns False when the user already has ``MAX_IN_FLIGHT_PER_USER``
requests in flight (an abuse guard, not a transient error) or when a
suspend does not finish within ``WAKE_TIMEOUT``.
"""
deadline = time.monotonic() + self.settings.wake_timeout deadline = time.monotonic() + self.settings.wake_timeout
cap = self.settings.max_in_flight_per_user
while True: while True:
async with self.state.lock: async with self.state.lock:
if not self.state.maintenance.get(user): if not self.state.maintenance.get(user):
self.state.in_flight[user] = self.state.in_flight.get(user, 0) + 1 current = self.state.in_flight.get(user, 0)
if cap > 0 and current >= cap:
return False
self.state.in_flight[user] = current + 1
self.state.last_activity[user] = time.time() self.state.last_activity[user] = time.time()
return True return True
if time.monotonic() > deadline: if time.monotonic() > deadline:
@@ -682,7 +884,12 @@ class Gateway:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
async def _release(self, user: str) -> None: async def _release(self, user: str) -> None:
async with self.state.lock: # Deliberately await-free: this runs from ``finally`` blocks, and the
# slot must be freed even when a client disconnect cancels the request
# mid-stream. Awaiting the lock there can be interrupted before the
# decrement, stranding the slot (the user's sandbox would then never
# look idle again). A plain dict update is atomic because the event
# loop cannot switch tasks between two synchronous statements.
self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0) self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0)
self.state.last_activity[user] = time.time() self.state.last_activity[user] = time.time()
@@ -749,14 +956,19 @@ class Gateway:
host: str, host: str,
force: bool = False, force: bool = False,
) -> tuple[str, tuple[str, str, str], bool]: ) -> tuple[str, tuple[str, str, str], bool]:
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.""" """Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.
Every failure raises ``HandshakeError`` with a fixed, credential-free
message: the exception reaches the browser in the 503 body, so it must
never carry the launch token, the handshake URL or pod details.
"""
pod_name = self._pod_name(sandbox) pod_name = self._pod_name(sandbox)
try: try:
pod = await self.k8s.get_pod(pod_name) pod = await self.k8s.get_pod(pod_name)
except ApiException as exc: except ApiException as exc:
raise HandshakeError(f"could not read pod {pod_name}: {exc}") from exc raise HandshakeError("could not read the DSH handshake pod") from exc
if pod is None: if pod is None:
raise HandshakeError(f"pod {pod_name} not found") raise HandshakeError("the DSH handshake pod is not available")
uid = (pod.get("metadata") or {}).get("uid") or pod_name uid = (pod.get("metadata") or {}).get("uid") or pod_name
key = (user, uid, host) key = (user, uid, host)
if not force: if not force:
@@ -770,7 +982,7 @@ class Gateway:
self.settings.dsh_log_tail_lines, self.settings.dsh_log_tail_lines,
) )
except ApiException as exc: except ApiException as exc:
raise HandshakeError(f"could not read logs for {pod_name}: {exc}") from exc raise HandshakeError("could not read the pod logs for the handshake") from exc
launch_token = parse_dsh_launch_token(logs) launch_token = parse_dsh_launch_token(logs)
if not launch_token: if not launch_token:
raise HandshakeError("DSH launch token not found in pod logs") raise HandshakeError("DSH launch token not found in pod logs")
@@ -788,12 +1000,12 @@ class Gateway:
timeout=self.settings.http_timeout, timeout=self.settings.http_timeout,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HandshakeError(f"DSH handshake failed: {exc}") from exc raise HandshakeError("the DSH handshake request failed") from exc
if response.status_code >= 400: if response.status_code >= 400:
raise HandshakeError(f"DSH handshake returned {response.status_code}") raise HandshakeError("the DSH handshake returned an error status")
cookie = extract_dsh_cookies(response.headers.get_list("set-cookie")) cookie = extract_dsh_cookies(response.headers.get_list("set-cookie"))
if not cookie: if not cookie:
raise HandshakeError("DSH handshake set no dsh-auth cookie") raise HandshakeError("the DSH handshake set no auth cookie")
self.state.handshake[key] = cookie self.state.handshake[key] = cookie
return cookie, key, False return cookie, key, False
@@ -869,9 +1081,9 @@ class Gateway:
if "set-cookie" in response.headers: if "set-cookie" in response.headers:
page.headers["set-cookie"] = response.headers["set-cookie"] page.headers["set-cookie"] = response.headers["set-cookie"]
return page return page
token, user = self.credentials(request.headers, request.cookies) _token, user = self.credentials(request.headers, request.cookies)
session = "" session = ""
if user and token: if user:
safe_user = html.escape(user) safe_user = html.escape(user)
session = ( session = (
f'<p class="session">Signed in as <strong>{safe_user}</strong>. ' f'<p class="session">Signed in as <strong>{safe_user}</strong>. '
@@ -879,6 +1091,11 @@ class Gateway:
'<form method="post" action="/logout">' '<form method="post" action="/logout">'
'<button type="submit">Sign out</button></form>' '<button type="submit">Sign out</button></form>'
) )
invite_input = (
'<input name="invite" type="password" placeholder="invite code" required>'
if self.settings.signup_invite_code is not None
else ""
)
body = f"""<!doctype html> body = f"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -901,6 +1118,7 @@ class Gateway:
<h2>Create a sandbox</h2> <h2>Create a sandbox</h2>
<form method="post" action="/users"> <form method="post" action="/users">
<input name="user" placeholder="username (dns-1123 label)" required> <input name="user" placeholder="username (dns-1123 label)" required>
{invite_input}
<button type="submit">Create</button> <button type="submit">Create</button>
</form> </form>
</section> </section>
@@ -960,11 +1178,18 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- users ------------------------------------------------------------- # -- users -------------------------------------------------------------
async def signup(self, request: Request) -> Response: async def signup(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request) wants_form = self._wants_form(request)
try: try:
payload = await self._read_payload(request) payload = await self._read_payload(request)
except BodyTooLarge: except BodyTooLarge:
return self._error(413, "request body too large") return self._error(413, "request body too large")
if not self._invite_allowed(request, payload):
return self._error(403, "invalid or missing invite code")
user = payload.get("user", "") user = payload.get("user", "")
if not valid_user(user): if not valid_user(user):
return self._error(400, "body must be {'user': '<dns-1123 label>'}") return self._error(400, "body must be {'user': '<dns-1123 label>'}")
@@ -982,16 +1207,21 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
sandbox_name = (sandbox or {}).get("metadata", {}).get("name") sandbox_name = (sandbox or {}).get("metadata", {}).get("name")
if wants_form: if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303) response = RedirectResponse(f"/u/{user}/", status_code=303)
self.set_session(response, user, token) self.set_session(response, user)
return response return response
response = JSONResponse( response = JSONResponse(
{"user": user, "token": token, "state": state, "sandbox": sandbox_name}, {"user": user, "token": token, "state": state, "sandbox": sandbox_name},
status_code=201 if state == "Ready" else 202, status_code=201 if state == "Ready" else 202,
) )
self.set_session(response, user, token) self.set_session(response, user)
return response return response
async def login(self, request: Request) -> Response: async def login(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request) wants_form = self._wants_form(request)
try: try:
payload = await self._read_payload(request) payload = await self._read_payload(request)
@@ -1002,21 +1232,29 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if not isinstance(user, str) or not isinstance(token, str): if not isinstance(user, str) or not isinstance(token, str):
return self._error(400, "user and token are required") return self._error(400, "user and token are required")
claim = await self.k8s.get_claim(user) claim = await self.k8s.get_claim(user)
if claim is None: # An unknown user is indistinguishable from a bad token to the client:
return self._error(404, "not found") # never reveal which usernames exist.
if not token_matches(claim, token): if claim is None or not token_matches(claim, token):
return self._error(401, "unauthorized") return self._error(401, "unauthorized")
await self._touch(user) await self._touch(user)
if wants_form: if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303) response = RedirectResponse(f"/u/{user}/", status_code=303)
else: else:
response = JSONResponse({"user": user, "state": "ok"}) response = JSONResponse({"user": user, "state": "ok"})
self.set_session(response, user, token) self.set_session(response, user)
return response return response
async def logout(self, request: Request) -> Response: async def logout(self, request: Request) -> Response:
self.revoke_session(request.cookies.get(self.settings.cookie_name))
response = RedirectResponse("/", status_code=303) response = RedirectResponse("/", status_code=303)
response.delete_cookie(self.settings.cookie_name, path="/") # Match the attributes the cookie was set with: a __Host- cookie
# deletion that lacks Secure is rejected by browsers, leaving a stale
# (though already revoked) cookie behind.
response.delete_cookie(
self.settings.cookie_name,
path="/",
secure=self.settings.cookie_secure,
)
return response return response
async def get_user(self, request: Request) -> Response: async def get_user(self, request: Request) -> Response:
@@ -1051,6 +1289,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "DELETE", cookie_user): if self._mutation_from_cookie(request.headers, "DELETE", cookie_user):
return self._error(403, "cross-origin request rejected") return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(user) await self.k8s.delete_claim(user)
self.revoke_sessions(user)
self._clear_handshake(user) self._clear_handshake(user)
async with self.state.lock: async with self.state.lock:
self.state.last_activity.pop(user, None) self.state.last_activity.pop(user, None)
@@ -1074,6 +1313,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "POST", cookie_user): if self._mutation_from_cookie(request.headers, "POST", cookie_user):
return self._error(403, "cross-origin request rejected") return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(remote) await self.k8s.delete_claim(remote)
self.revoke_sessions(remote)
self._clear_handshake(remote) self._clear_handshake(remote)
async with self.state.lock: async with self.state.lock:
self.state.last_activity.pop(remote, None) self.state.last_activity.pop(remote, None)
@@ -1101,7 +1341,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
return self._error(404, "unknown user") return self._error(404, "unknown user")
token, cookie_user = self.credentials(request.headers, request.cookies) token, cookie_user = self.credentials(request.headers, request.cookies)
if remote == user and not (cookie_user == user and token_matches(claim, token)): if remote == user and cookie_user != user:
# SSO user without a valid session cookie: bootstrap, then continue. # SSO user without a valid session cookie: bootstrap, then continue.
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not self._authorized(claim, token, cookie_user, user, remote): if not self._authorized(claim, token, cookie_user, user, remote):
@@ -1161,11 +1401,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if query: if query:
url = f"{url}?{query}" url = f"{url}?{query}"
headers = { headers = {}
key: value allowlist = self.settings.upstream_header_allowlist
for key, value in request.headers.items() for key, value in request.headers.items():
if key.lower() not in OWNED_REQUEST_HEADERS and key.lower() != "content-length" lowered = key.lower()
} if lowered in OWNED_REQUEST_HEADERS or lowered == "content-length":
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers are spoofable
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
host = request.headers.get("host", "") host = request.headers.get("host", "")
headers["host"] = host headers["host"] = host
headers["cookie"] = cookie # only the cached DSH auth cookie headers["cookie"] = cookie # only the cached DSH auth cookie
@@ -1238,8 +1484,16 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
print(f"stream for {user} ended early: {exc!r}", flush=True) print(f"stream for {user} ended early: {exc!r}", flush=True)
return return
finally: finally:
# The slot must be freed even when this ``finally`` runs inside a
# cancelled task: on a client disconnect Starlette cancels the
# streaming task through an anyio cancel scope, and CancelledError
# is a BaseException, so ``suppress(Exception)`` would let a close
# that awaits abort the whole block before ``_release`` ran. A
# stranded slot keeps the user's sandbox looking busy forever.
try:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await upstream.aclose() await upstream.aclose()
finally:
await self._release(user) await self._release(user)
# -- WebSocket proxy --------------------------------------------------- # -- WebSocket proxy ---------------------------------------------------
@@ -1291,13 +1545,19 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
query = filtered_query(websocket.url.query) query = filtered_query(websocket.url.query)
if query: if query:
url = f"{url}?{query}" url = f"{url}?{query}"
headers = { headers = {}
key: value allowlist = self.settings.upstream_header_allowlist
for key, value in websocket.headers.items() for key, value in websocket.headers.items():
if key.lower() not in OWNED_REQUEST_HEADERS lowered = key.lower()
and key.lower() != "origin" if lowered in OWNED_REQUEST_HEADERS or lowered == "origin":
and not key.lower().startswith("sec-websocket-") continue
} if lowered.startswith("sec-websocket-"):
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
headers["cookie"] = cookie headers["cookie"] = cookie
headers["x-forwarded-host"] = host headers["x-forwarded-host"] = host
headers["x-forwarded-proto"] = websocket.url.scheme headers["x-forwarded-proto"] = websocket.url.scheme
@@ -1313,7 +1573,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
additional_headers=headers, additional_headers=headers,
subprotocols=subprotocols or None, subprotocols=subprotocols or None,
open_timeout=self.settings.wake_timeout, open_timeout=self.settings.wake_timeout,
max_size=None, max_size=self.settings.ws_max_message_bytes,
proxy=None, proxy=None,
) )
upstream = await upstream_cm.__aenter__() upstream = await upstream_cm.__aenter__()
@@ -1373,8 +1633,41 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- idle sweeper ------------------------------------------------------ # -- idle sweeper ------------------------------------------------------
def _prune_state(self, claims: list[dict]) -> None:
"""Drop state that can no longer be reached.
Sessions and activity clocks for a user whose claim is gone, expired
sessions and expired rate-limit buckets would otherwise accumulate for
the lifetime of the process. In-flight counters are only pruned at
zero, and a user with work in flight is always preserved.
"""
now = time.time()
present = set()
for claim in claims:
name = (claim.get("metadata") or {}).get("name") or ""
if name.startswith("dsh-"):
present.add(name[len("dsh-") :])
for user in [user for user in self.state.last_activity if user not in present]:
self.state.last_activity.pop(user, None)
for user, count in list(self.state.in_flight.items()):
if user not in present and count == 0:
self.state.in_flight.pop(user, None)
for user, flag in list(self.state.maintenance.items()):
if user not in present and not flag:
self.state.maintenance.pop(user, None)
for key in [key for key in self.state.handshake if key[0] not in present]:
self.state.handshake.pop(key, None)
for sid, session in list(self.state.sessions.items()):
if session.user not in present or session.expires_at <= now:
self.state.sessions.pop(sid, None)
window = max(1, self.settings.auth_rate_window)
for key, (start, _count) in list(self.state.auth_rate.items()):
if now - start >= window:
self.state.auth_rate.pop(key, None)
async def sweep_once(self) -> None: async def sweep_once(self) -> None:
claims = await self.k8s.list_claims() claims = await self.k8s.list_claims()
self._prune_state(claims)
for claim in claims: for claim in claims:
# Only suspend claims this gateway created. Everything else in # Only suspend claims this gateway created. Everything else in
# the namespace (resource-layer claims from the README walkthrough, # the namespace (resource-layer claims from the README walkthrough,
@@ -1439,6 +1732,20 @@ def _lifespan(gateway: Gateway, close_http_client: httpx.AsyncClient | None = No
return lifespan return lifespan
async def client_gone(request: Request, exc: ClientDisconnect) -> Response:
"""Answer a client that vanished mid-request instead of logging a traceback.
Reading a request body raises ``ClientDisconnect`` when a browser reload, a
back navigation or an ingress timeout aborts the upload. The request never
reached DSH, and nothing can be delivered to the peer that left, so the
honest outcome is 499 (nginx's "client closed request"). Letting it escape
would make Starlette's ``ServerErrorMiddleware`` log one ERROR traceback per
aborted request, which buries real faults. It also covers the control
routes that read a body (signup and login), not just the proxy.
"""
return Response(status_code=CLIENT_CLOSED_REQUEST)
def create_app( def create_app(
settings: Settings | None = None, settings: Settings | None = None,
k8s=None, k8s=None,
@@ -1471,6 +1778,7 @@ def create_app(
] ]
app = Starlette( app = Starlette(
routes=routes, routes=routes,
exception_handlers={ClientDisconnect: client_gone},
lifespan=_lifespan(gateway, http_client if owns_http_client else None), lifespan=_lifespan(gateway, http_client if owns_http_client else None),
) )
app.state.gateway = gateway app.state.gateway = gateway

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,15 @@ spec:
# be blocked. See docs/optional-hardening.md. # be blocked. See docs/optional-hardening.md.
networkPolicyManagement: Unmanaged networkPolicyManagement: Unmanaged
# OPTIONAL: replace a Failed sandbox Pod instead of parking the sandbox.
# The default, Retain, leaves the Failed Pod in place and surfaces
# Finished=True on the Sandbox until a human intervenes. Recreate deletes the
# Failed Pod and starts a replacement with the same Sandbox identity and
# Sandbox-owned PVCs, backing off (5s doubling to 5m) on repeated failures.
# Requires agent-sandbox v1.0.8+; existing Sandboxes keep their own stamped
# policy, so patch them separately. See docs/optional-hardening.md.
# failedPodPolicy: Recreate
podTemplate: podTemplate:
spec: spec:
# DSH Web is multi-arch (amd64 + arm64); no arch nodeSelector is needed. # DSH Web is multi-arch (amd64 + arm64); no arch nodeSelector is needed.
@@ -60,11 +69,11 @@ spec:
# OnRootMismatch skips that walk once the volume root already carries # OnRootMismatch skips that walk once the volume root already carries
# the fsGroup ownership/mode applied on the first mount. # the fsGroup ownership/mode applied on the first mount.
# Keep in sync with the minimal variant: # Keep in sync with the minimal variant:
# examples/minimal-single-tenant/manifests/sandbox-template.yaml. # examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml.
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
containers: containers:
- name: dsh-web - name: dsh-web
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: web - name: web
@@ -80,6 +89,23 @@ spec:
name: dsh-provider-keys-extra name: dsh-provider-keys-extra
optional: true optional: true
env: env:
# Point the OS home at the persisted agent workspace. DSH's
# directory picker (the folder explorer used to choose a workspace)
# has no start-path setting: when it opens it lists $HOME, and
# os.homedir() follows this variable. The image default is
# /home/node, which is not on a volume, so the picker would open on
# a throwaway directory; /workspace is the PVC-backed WORKDIR. `~`
# in agent commands resolves here too. Trade-off: tools that keep
# caches/config under $HOME (npm/pnpm, uv, git, gh, and DSH's global
# ~/.agents instructions and skills) now write dotfiles onto the PVC
# inside the agent workspace, so they are visible to the agent and
# in the file explorer. DSH_HOME is explicit (/home/node/.dsh), so
# sessions, config and credentials are unaffected. Existing
# sandboxes stamp pod env at creation and keep the old HOME until
# recreated. Keep in sync with the minimal variant:
# examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml.
- name: HOME
value: /workspace
# Empty => DSH derives links from the request Host, which the # Empty => DSH derives links from the request Host, which the
# gateway forwards (plus X-Forwarded-*). Point this at the # gateway forwards (plus X-Forwarded-*). Point this at the
# gateway base URL if you want hard-coded absolute links instead. # gateway base URL if you want hard-coded absolute links instead.
@@ -93,6 +119,38 @@ spec:
# dsh-demo.example.com for the gateway-api-sso/ HTTPRoute). # dsh-demo.example.com for the gateway-api-sso/ HTTPRoute).
- name: DSH_TRUSTED_HOSTS - name: DSH_TRUSTED_HOSTS
value: "" value: ""
# DSH confines every bash command with its own file sandbox
# (bubblewrap, else Landlock). Neither backend is usable inside this
# pod: bubblewrap ships in the image but is unsetuid, so it needs an
# unprivileged user namespace that the pod's RuntimeDefault seccomp
# and dropped capabilities do not grant, and the runtime/kernel
# surface here exposes no Landlock runner. The executor therefore
# fails closed with SANDBOX_UNAVAILABLE and every command fails. The
# pod is the isolation boundary in this example (one pod
# per user, non-root, capabilities dropped, seccomp RuntimeDefault,
# a private PVC), so trade DSH's inner file policy for a working
# agent. Set this to workspace-write (or read-only) on hosts where a
# backend is available; see the README troubleshooting row.
# SECURITY: the pod is the isolation boundary in this example.
# danger-full-access is set only because DSH's own command sandbox
# has no usable backend in this pod (see above), which means an
# agent command can read its own DSH_HOME (including
# .credentials.yaml) and write anywhere in the container. What
# still contains it is the pod itself: one pod per user, non-root,
# all capabilities dropped, RuntimeDefault seccomp, its own PVC and
# Service. That is NOT a hardened multi-tenant boundary on a shared
# kernel. For production/multi-tenant use, additionally run the
# sandbox under gVisor (`runtimeClassName: gvisor` above) so a
# container escape is trapped in a userspace kernel, and apply the
# sandbox NetworkPolicy (manifests/optional/network-policy/) so
# tenants cannot reach each other or cluster-internal endpoints.
# The base manifests without those two controls are a demo. Treat
# danger-full-access as a fallback, not a target: on hosts where
# DSH's command-sandbox backend works, prefer workspace-write (or
# read-only), and recreate the pod because the value is stamped at
# creation.
- name: DSH_PERMISSION_MODE
value: danger-full-access
# Probes target /favicon.svg: DSH gates / behind its launch-token # Probes target /favicon.svg: DSH gates / behind its launch-token
# cookie and answers 401 to an unauthenticated GET, which would # cookie and answers 401 to an unauthenticated GET, which would
# otherwise fail readiness and then get the pod killed by liveness. # otherwise fail readiness and then get the pod killed by liveness.
@@ -126,7 +184,8 @@ spec:
periodSeconds: 15 periodSeconds: 15
failureThreshold: 3 failureThreshold: 3
# The container only needs to serve HTTP; drop every capability and # The container only needs to serve HTTP; drop every capability and
# disallow privilege escalation. DSH writes only to DSH_HOME (PVC). # disallow privilege escalation. DSH writes only to its PVC-backed
# directories (DSH_HOME and the agent workspace).
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: capabilities:
@@ -139,6 +198,16 @@ spec:
volumeMounts: volumeMounts:
- name: dsh-home - name: dsh-home
mountPath: /home/node/.dsh # DSH_HOME: sessions, config, state mountPath: /home/node/.dsh # DSH_HOME: sessions, config, state
# The agent workspace (the image's WORKDIR) is PVC-backed too, but
# under its own subPath so DSH state - including .credentials.yaml -
# never shows up as workspace data. Kubelet creates workspace/ on
# first mount with the volume's fsGroup permissions (0775, setgid
# inherited from the volume root), so UID 1000 can write; no init
# container is needed. If the kubelet is killed mid-creation the
# directory stays 0755: see Troubleshooting in the README.
- name: dsh-home
mountPath: /workspace
subPath: workspace
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - metadata:
name: dsh-home name: dsh-home

View File

@@ -101,7 +101,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: gateway - name: gateway
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4 image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@@ -134,14 +134,30 @@ spec:
# the DSH launch token. # the DSH launch token.
- name: DSH_CONTAINER - name: DSH_CONTAINER
value: dsh-web value: dsh-web
# Cookie dsh_gw_session is HttpOnly + SameSite=Lax. "false" keeps # Browser session cookie: HttpOnly + SameSite=Lax, holds only an
# the plain-HTTP demo usable through port-forward: a Secure cookie # opaque, server-side session id (sid) - never the user's DSH
# is never sent over HTTP, so login would silently fail. # token. Sessions can be revoked server-side: POST /logout, user
# HTTPS deployments (the ingress/ variant with TLS, or # delete, POST /recreate and expiry (COOKIE_MAX_AGE, default
# gateway-api-sso/, which terminates TLS at the Gateway) MUST set # 7 days) all invalidate them. The session table is in process
# this to "true". # memory, so restarting this Deployment logs every browser out.
# With COOKIE_SECURE=true the cookie is named
# __Host-dsh_gw_session; COOKIE_NAME overrides the name.
#
# "false" keeps the plain-HTTP demo usable through port-forward: a
# Secure cookie is never sent over HTTP, so login would silently
# fail. The gateway REFUSES TO START with COOKIE_SECURE=false
# unless ALLOW_INSECURE_COOKIES=1 acknowledges that the session
# travels in clear text, so the base demo sets both. HTTPS
# deployments (the ingress/ variant with TLS, or gateway-api-sso/,
# which terminates TLS at the Gateway) MUST set this to "true".
- name: COOKIE_SECURE - name: COOKIE_SECURE
value: "false" value: "false"
# Explicit acknowledgement that this base demo serves plain HTTP.
# Required when COOKIE_SECURE=false (the gateway exits at startup
# otherwise); ignored once COOKIE_SECURE=true, so HTTPS installs
# should drop it. Never set this on an internet-facing deployment.
- name: ALLOW_INSECURE_COOKIES
value: "1"
# SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the # SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the
# gateway trusts only its own bearer/session auth and ignores every # gateway trusts only its own bearer/session auth and ignores every
# identity header. Enable it ONLY behind # identity header. Enable it ONLY behind
@@ -152,9 +168,49 @@ spec:
# reach :8080 can impersonate any user by sending that header. # reach :8080 can impersonate any user by sending that header.
# - name: AUTH_PROXY_HEADER # - name: AUTH_PROXY_HEADER
# value: X-Forwarded-User # value: X-Forwarded-User
# Brute-force / abuse throttle for the unauthenticated auth
# endpoints (/users and /login): at most N attempts per client per
# AUTH_RATE_WINDOW seconds (default 60). A client is the rightmost
# X-Forwarded-For entry (the one added by the trusted proxy) when
# present, otherwise the TCP peer. Over the limit the gateway
# answers 429 + Retry-After BEFORE reading the body or touching the
# cluster; 0 disables the limiter. This is a per-process bucket, so
# it is not a substitute for an ingress rate limit.
- name: AUTH_RATE_LIMIT
value: "30"
# Window in seconds for AUTH_RATE_LIMIT (default 60).
# - name: AUTH_RATE_WINDOW
# value: "60"
# Optional signup gate. When set, POST /users requires the invite
# code - form field "invite" or header X-Signup-Invite - compared in
# constant time. A missing/wrong code is rejected with 403 before
# the user is looked up, so it also stops unauthenticated claim
# creation. The landing page shows the invite input only when this
# is set. Keep the value in a Secret, create it before rollout and
# restart the gateway to pick up a rotation:
# kubectl -n dsh-demo create secret generic dsh-signup-invite \
# --from-literal=code="$(openssl rand -hex 16)"
# - name: SIGNUP_INVITE_CODE
# valueFrom:
# secretKeyRef:
# name: dsh-signup-invite
# key: code
# Reject proxied request bodies larger than this (413). # Reject proxied request bodies larger than this (413).
- name: MAX_BODY_MB - name: MAX_BODY_MB
value: "32" value: "32"
# Per-user concurrency guard (default 32): at most this many
# proxied HTTP requests and WebSocket connections per user.
# Excess HTTP requests get 503 + Retry-After and excess WebSocket
# upgrades close with 1011, so one tenant cannot exhaust the
# single-replica gateway for everyone else.
# - name: MAX_IN_FLIGHT_PER_USER
# value: "32"
# Largest WebSocket message received from DSH, in bytes (default
# 16 MiB); oversized frames close the connection. Browser-to-DSH
# frames are bounded by uvicorn's own limit. HTTP/SSE responses are
# streamed without this cap.
# - name: WS_MAX_MESSAGE_BYTES
# value: "16777216"
# /healthz is unauthenticated and returns 200 without touching the # /healthz is unauthenticated and returns 200 without touching the
# cluster, so it is safe for both readiness and liveness. # cluster, so it is safe for both readiness and liveness.
readinessProbe: readinessProbe:

View File

@@ -16,6 +16,16 @@ Applying both gives the gateway two entry points, and the SSO variant is only
safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not
mix them. mix them.
> **Production requirement.** Each variant's NetworkPolicy is part of the
> variant, and applying the variant without it is demo-only. The sandbox
> NetworkPolicy and the gateway egress policy (the API server is cluster-specific)
> are separate and equally required; see the
> [production checklist](../../docs/optional-hardening.md#6-production-checklist)
> and the
> [gateway egress recipe](../../docs/optional-hardening.md#gateway-egress-required-for-production).
> For SSO, run [`scripts/preflight-sso.sh`](../../scripts/preflight-sso.sh)
> after applying the variant and before sending real traffic.
Each variant also ships a NetworkPolicy, applied by the same directory apply: Each variant also ships a NetworkPolicy, applied by the same directory apply:
- `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway - `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway
@@ -27,7 +37,8 @@ Each variant also ships a NetworkPolicy, applied by the same directory apply:
The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label. The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label.
Adjust the selector for another implementation or a `hostNetwork` install; see Adjust the selector for another implementation or a `hostNetwork` install; see
[optional hardening](../../docs/optional-hardening.md) for the probe caveat and [optional hardening](../../docs/optional-hardening.md) for the probe caveat and
verification commands. verification commands. Keep the policies: an exposure without its NetworkPolicy
is demo-only.
## Applying a variant ## Applying a variant
@@ -69,10 +80,13 @@ Secrets imperatively (commands below) before applying the directory.
2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host: 2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host:
the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on
`/api` (see `../10-sandboxtemplate.yaml`). `/api` (see `../10-sandboxtemplate.yaml`).
3. Set `COOKIE_SECURE=true` on the gateway once the Ingress serves HTTPS: 3. Set `COOKIE_SECURE=true` and remove the plain-HTTP acknowledgement once the
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`. The Ingress serves HTTPS:
base manifest keeps `false` so the plain-HTTP port-forward demo works; a `kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
`Secure` cookie is never sent over HTTP, which silently breaks login. The base manifest keeps `false` + `ALLOW_INSECURE_COOKIES=1` so the
plain-HTTP port-forward demo works; a `Secure` cookie is never sent over HTTP,
which silently breaks login, while the acknowledgement must not survive into
production.
4. Do not attach buffering or compression middlewares to this router: they 4. Do not attach buffering or compression middlewares to this router: they
break WebSocket upgrades and streaming. break WebSocket upgrades and streaming.
5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods 5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods
@@ -116,14 +130,23 @@ kubectl -n dsh-demo create secret tls dsh-demo-tls --cert=tls.crt --key=tls.key
# 3. Enable SSO on the gateway and trust the public host in the template. # 3. Enable SSO on the gateway and trust the public host in the template.
kubectl -n dsh-demo set env deploy/dsh-gateway \ kubectl -n dsh-demo set env deploy/dsh-gateway \
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
# then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate # then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate
# (see ../10-sandboxtemplate.yaml) and re-apply it. # (see ../10-sandboxtemplate.yaml) and re-apply it.
# 4. Apply the variant. # 4. Apply the variant.
kubectl apply -f manifests/exposure/gateway-api-sso/ kubectl apply -f manifests/exposure/gateway-api-sso/
# 5. Preflight: fails loudly on missing Secrets, a bad oauth2-proxy cookie
# secret, missing gateway SSO env, a dangling TLS reference, leftover
# placeholders, or a second path to the gateway.
./scripts/preflight-sso.sh
``` ```
The preflight is read-only and exits non-zero on any failure, naming the exact
fix. Run it again after rotating a Secret or changing the public host; it also
verifies that the oauth2-proxy cookie secret decodes to 16, 24 or 32 bytes.
### SSO trust boundary ### SSO trust boundary
`AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway `AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway
@@ -141,8 +164,14 @@ takes all of these:
impersonate any user. impersonate any user.
3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in 3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in
the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy
egress). A custom exposure without an equivalent policy has no network egress). This is required, not optional: an exposure without the policy is
demo-only, and a custom exposure without an equivalent policy has no network
enforcement. enforcement.
4. Apply the gateway egress policy too: the gateway talks to the Kubernetes API
and to sandbox pods, and `75-networkpolicies.yaml` intentionally leaves that
direction unrestricted because the API-server endpoint is cluster-specific.
See
[gateway egress](../../docs/optional-hardening.md#gateway-egress-required-for-production).
Also terminate TLS in front of the gateway (the Gateway listener does) and Also terminate TLS in front of the gateway (the Gateway listener does) and
leave `COOKIE_SECURE=true` so the session cookie never travels in clear text. leave `COOKIE_SECURE=true` so the session cookie never travels in clear text.

View File

@@ -25,8 +25,9 @@
# TLS terminates at the Gateway listener (70-gateway.yaml); keep # TLS terminates at the Gateway listener (70-gateway.yaml); keep
# --cookie-secure=true and COOKIE_SECURE=true on the gateway. # --cookie-secure=true and COOKIE_SECURE=true on the gateway.
# #
# The image is pinned to quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 # The image is digest-pinned: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:...
# (versions.env: OAUTH2_PROXY_IMAGE). Credentials come from the # (versions.env: OAUTH2_PROXY_IMAGE). The digest is the immutable multi-arch
# index, so the tag can never be moved under us. Credentials come from the
# "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this # "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this
# manifest never carries secrets. # manifest never carries secrets.
apiVersion: apps/v1 apiVersion: apps/v1
@@ -61,7 +62,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: oauth2-proxy - name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false

View File

@@ -1,5 +1,5 @@
# OPTIONAL with the SSO exposure variant: enforce the trust boundary at the # REQUIRED with the SSO exposure variant: enforce the trust boundary at the
# network layer. # network layer. Applying the SSO variant without this file is demo-only.
# #
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These # The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
# policies make it the ONLY chain: # policies make it the ONLY chain:
@@ -8,8 +8,9 @@
# - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the # - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the
# gateway. # gateway.
# The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not # The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not
# restricted here: the API-server endpoint is cluster-specific. See # restricted here: the API-server endpoint is cluster-specific. Add it for
# ../../../docs/optional-hardening.md for that recipe. # production. See
# ../../../docs/optional-hardening.md#gateway-egress-required-for-production.
# #
# This manifest assumes Traefik, matching gatewayClassName: traefik in # This manifest assumes Traefik, matching gatewayClassName: traefik in
# 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another # 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another

View File

@@ -14,8 +14,13 @@
# DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin # DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin
# on /api. # on /api.
# 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to # 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to
# "true" once this Ingress serves HTTPS, so the session cookie is Secure. # "true" and remove ALLOW_INSECURE_COOKIES once this Ingress serves HTTPS,
# The base manifest sets "false" for the plain-HTTP port-forward demo. # so the session cookie is Secure:
# kubectl -n dsh-demo set env deploy/dsh-gateway \
# COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
# The base manifest sets "false" + ALLOW_INSECURE_COOKIES=1 for the
# plain-HTTP port-forward demo; without the acknowledgement the gateway
# refuses to start.
# #
# WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH # WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH
# uses WebSockets for live session traffic. Traefik upgrades WS/SSE # uses WebSockets for live session traffic. Traefik upgrades WS/SSE
@@ -24,9 +29,11 @@
# and upgrades. `path: /` Prefix covers the landing page and every per-user # and upgrades. `path: /` Prefix covers the landing page and every per-user
# proxy path, so no per-sandbox Ingress is needed. # proxy path, so no per-sandbox Ingress is needed.
# #
# 61-networkpolicy.yaml is part of this variant: it restricts the gateway's # 61-networkpolicy.yaml is part of this variant and required for production:
# ingress to Traefik pods. Adjust its selector if your ingress controller uses # it restricts the gateway's ingress to Traefik pods. Adjust its selector if
# a different label or hostNetwork. # your ingress controller uses a different label or hostNetwork. The sandbox
# and gateway-egress NetworkPolicies are separate requirements; see
# ../../../docs/optional-hardening.md#6-production-checklist.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:

View File

@@ -1,11 +1,16 @@
# OPTIONAL with the Ingress exposure variant: restrict the gateway's ingress # REQUIRED with the Ingress exposure variant for production: restrict the
# to the ingress controller. # gateway's ingress to the ingress controller. Applying the variant without
# this policy is demo-only.
# #
# This manifest assumes Traefik, matching this variant's # This manifest assumes Traefik, matching this variant's
# `ingressClassName: traefik`. Traefik's Helm chart labels its pods # `ingressClassName: traefik`. Traefik's Helm chart labels its pods
# app.kubernetes.io/name=traefik; adjust the selector for another controller or # app.kubernetes.io/name=traefik; adjust the selector for another controller or
# for a hostNetwork install (where the source is the node IP, not a pod IP). # for a hostNetwork install (where the source is the node IP, not a pod IP).
# #
# The sandbox NetworkPolicy and the gateway egress policy are separate and also
# required for production; see
# ../../../docs/optional-hardening.md#6-production-checklist.
#
# NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the # NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the
# gateway starts failing its /healthz probes, allow the node CIDR on port 8080 # gateway starts failing its /healthz probes, allow the node CIDR on port 8080
# or remove this policy. See ../../../docs/optional-hardening.md. # or remove this policy. See ../../../docs/optional-hardening.md.

View File

@@ -23,6 +23,12 @@ It requires a CNI that enforces `NetworkPolicy`. The selector uses the
agent-sandbox controller's pod tracking label, so the policy applies to agent-sandbox controller's pod tracking label, so the policy applies to
existing warm-pool and claimed sandboxes without recreation. existing warm-pool and claimed sandboxes without recreation.
**This is opt-in for the demo but required for production.** Without it every
tenant can reach every other tenant and cluster-internal endpoints. Production
also needs the gateway egress policy (cluster-specific API-server endpoint) and
the exposure variant's policy; see the
[production checklist](../../docs/optional-hardening.md#6-production-checklist).
## gVisor, nodeSelector and provider keys ## gVisor, nodeSelector and provider keys
These are configured in the main manifests or with an extra Secret: These are configured in the main manifests or with an extra Secret:
@@ -30,6 +36,9 @@ These are configured in the main manifests or with an extra Secret:
- **gVisor:** uncomment `runtimeClassName: gvisor` in the SandboxTemplate and - **gVisor:** uncomment `runtimeClassName: gvisor` in the SandboxTemplate and
the Deployments before applying, or patch an existing install. the Deployments before applying, or patch an existing install.
- **nodeSelector:** uncomment the field and replace the placeholder node label. - **nodeSelector:** uncomment the field and replace the placeholder node label.
- **Failed-Pod recovery:** uncomment `failedPodPolicy: Recreate` in the
SandboxTemplate (agent-sandbox v1.0.8+) so a Failed Pod is replaced instead
of parked.
- **Provider keys:** create the optional `dsh-provider-keys-extra` Secret; the - **Provider keys:** create the optional `dsh-provider-keys-extra` Secret; the
base `dsh-provider-keys` Secret remains the one required key. base `dsh-provider-keys` Secret remains the one required key.

View File

@@ -1,9 +1,15 @@
# OPTIONAL, opt-in tenant network policy for the DSH sandboxes. # OPTIONAL for the base walkthrough, REQUIRED for production. Opt-in tenant
# network policy for the DSH sandboxes: without it every tenant can reach every
# other tenant and cluster-internal endpoints.
# #
# It is NOT part of the top-level walkthrough. Apply and delete it explicitly: # It is NOT part of the top-level walkthrough. Apply and delete it explicitly:
# kubectl apply -f manifests/optional/network-policy/ # kubectl apply -f manifests/optional/network-policy/
# kubectl delete -f manifests/optional/network-policy/ # kubectl delete -f manifests/optional/network-policy/
# #
# Production also needs the gateway egress policy (cluster-specific API-server
# endpoint) and the exposure variant's policy, plus gVisor
# (docs/optional-hardening.md#6-production-checklist).
#
# The selector uses the agent-sandbox controller's pod tracking label, which is # The selector uses the agent-sandbox controller's pod tracking label, which is
# present on every Sandbox pod (including warm-pool spares), so the policy # present on every Sandbox pod (including warm-pool spares), so the policy
# applies to existing sandboxes without recreating them. The SandboxTemplate # applies to existing sandboxes without recreating them. The SandboxTemplate

View File

@@ -72,8 +72,23 @@ pass "claim Ready in ${ELAPSED}s, adopted $SB"
echo "=== 3. write state on the PVC" echo "=== 3. write state on the PVC"
POD="$SB" POD="$SB"
# HOME must point at the PVC-backed workspace: DSH's directory picker lists
# $HOME when it opens and has no start-path setting, so this is what makes the
# folder explorer start on the persisted tree instead of the ephemeral image
# home. It also makes ~ resolve to /workspace inside agent commands.
HOME_VALUE=$(kubectl -n "$NS" exec "$POD" -- printenv HOME)
[ "$HOME_VALUE" = "/workspace" ] || fail "HOME is '$HOME_VALUE', expected /workspace"
TILDE=$(kubectl -n "$NS" exec "$POD" -- sh -c 'cd ~ && pwd')
[ "$TILDE" = "/workspace" ] || fail "~ resolves to '$TILDE', expected /workspace"
pass "HOME=$HOME_VALUE and ~ resolve to the workspace"
kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt' kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
pass "marker written in $POD" pass "state marker written in $POD"
# The agent workspace must be the dsh-home PVC's workspace/ subPath, writable
# as UID 1000 without an init container.
SUBPATH=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{range .spec.containers[0].volumeMounts[?(@.mountPath=="/workspace")]}{.subPath}{end}')
[ "$SUBPATH" = "workspace" ] || fail "/workspace is not mounted from the dsh-home PVC subPath"
kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me too" > /workspace/marker.txt'
pass "workspace marker written in $POD (subPath=$SUBPATH)"
echo "=== 4. suspend (pod deleted, PVC + Service retained)" echo "=== 4. suspend (pod deleted, PVC + Service retained)"
kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Suspended"}}' kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Suspended"}}'
@@ -87,8 +102,11 @@ kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"
kubectl -n "$NS" wait sandbox "$SB" --for=condition=Ready --timeout=180s kubectl -n "$NS" wait sandbox "$SB" --for=condition=Ready --timeout=180s
POD="$SB" POD="$SB"
MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /home/node/.dsh/marker.txt) MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /home/node/.dsh/marker.txt)
[ "$MARKER" = "remember me" ] || fail "marker did not survive suspend/resume" [ "$MARKER" = "remember me" ] || fail "state marker did not survive suspend/resume"
pass "resumed: state survived on the reattached PVC" pass "resumed: DSH state survived on the reattached PVC"
WS_MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /workspace/marker.txt)
[ "$WS_MARKER" = "remember me too" ] || fail "workspace marker did not survive suspend/resume"
pass "resumed: agent workspace survived on the PVC subPath"
echo "=== 6. injection policy rejects env-carrying claims" echo "=== 6. injection policy rejects env-carrying claims"
kubectl apply -f manifests/40-claim-rejected.yaml kubectl apply -f manifests/40-claim-rejected.yaml

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env bash
# Preflight for the gateway-api-sso exposure variant.
#
# Run this AFTER creating the oauth2-proxy/TLS Secrets, setting the gateway
# env and applying manifests/exposure/gateway-api-sso/, and BEFORE sending real
# traffic. It checks the configuration that is easy to get wrong and whose
# failure is either silent (login appears to work but the gateway still asks
# for its own token) or dangerous (a spoofable identity header, a placeholder
# host, a TLS Secret that does not exist).
#
# Checks:
# 1. kubectl reaches the cluster and the SSO objects exist;
# 2. the oauth2-proxy Secret has client-id, client-secret and a cookie-secret
# that decodes to 16, 24 or 32 bytes; the TLS Secret exists;
# 3. deploy/dsh-gateway has AUTH_PROXY_HEADER=X-Forwarded-User and
# COOKIE_SECURE=true (both are opt-in and both are required for SSO over
# TLS);
# 4. the Gateway's TLS certificate reference resolves to an existing Secret;
# 5. no your-github-org / dsh-demo.example.com placeholder is left in the
# applied oauth2-proxy args, Gateway listeners or HTTPRoute hostnames;
# 6. (warning) no Ingress in the namespace creates a second path to
# dsh-gateway. With SSO enabled the Service must be reachable only through
# oauth2-proxy.
#
# Exit status: 0 when every check passes (warnings allowed), 1 on any failure.
# Every failure prints the fix. This script only reads cluster state.
#
# Usage:
# examples/dsh-agents-as-a-service/scripts/preflight-sso.sh
#
# Environment overrides:
# NAMESPACE namespace (default: dsh-demo)
# GATEWAY_DEPLOY gateway Deployment name (default: dsh-gateway)
# OAUTH_DEPLOY oauth2-proxy Deployment name (default: oauth2-proxy)
# OAUTH_SECRET oauth2-proxy Secret name (default: oauth2-proxy)
# TLS_SECRET TLS Secret name the Gateway should reference (default:
# dsh-demo-tls)
set -euo pipefail
cd "$(dirname "$0")/.."
NS="${NAMESPACE:-dsh-demo}"
GATEWAY_DEPLOY="${GATEWAY_DEPLOY:-dsh-gateway}"
OAUTH_DEPLOY="${OAUTH_DEPLOY:-oauth2-proxy}"
OAUTH_SECRET="${OAUTH_SECRET:-oauth2-proxy}"
TLS_SECRET="${TLS_SECRET:-dsh-demo-tls}"
FAILED=0
pass() { printf 'PASS %s\n' "$*"; }
warn() { printf 'WARN %s\n' "$*" >&2; }
fail() { printf 'FAIL %s\n' "$*" >&2; FAILED=1; }
if ! command -v kubectl >/dev/null 2>&1; then
echo "FAIL kubectl is not installed; install it and select the target context" >&2
exit 1
fi
if ! kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" >/dev/null 2>&1; then
echo "FAIL deploy/$GATEWAY_DEPLOY not found in namespace $NS." >&2
echo " Apply the base manifests first: kubectl apply -f manifests/" >&2
echo " Then apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/" >&2
exit 1
fi
echo "== Secrets"
# check_secret_key <secret> <key> <hint>
check_secret_key() {
local secret=$1 key=$2 hint=$3 value escaped
# Kubernetes JSONPath treats "." as a path separator; escape it in keys such
# as tls.crt.
escaped=${key//./\\.}
value=$(kubectl -n "$NS" get secret "$secret" -o jsonpath="{.data.${escaped}}" 2>/dev/null || true)
if [ -n "$value" ]; then
pass "Secret $secret has non-empty key '$key'"
else
fail "Secret $secret is missing a non-empty '$key' key. $hint"
fi
}
check_secret_key "$OAUTH_SECRET" client-id \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
check_secret_key "$OAUTH_SECRET" client-secret \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
check_secret_key "$OAUTH_SECRET" cookie-secret \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
# The cookie secret must be URL-safe unpadded base64 of 16, 24 or 32 bytes;
# oauth2-proxy refuses anything else (the classic error is "44 bytes").
if COOKIE_B64=$(kubectl -n "$NS" get secret "$OAUTH_SECRET" \
-o jsonpath='{.data.cookie-secret}' 2>/dev/null); then
if [ -n "$COOKIE_B64" ]; then
if ! COOKIE_LEN=$(printf '%s' "$COOKIE_B64" | base64 -d 2>/dev/null | wc -c); then
fail "Secret $OAUTH_SECRET key 'cookie-secret' is not valid base64. Generate it with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
else
case "$COOKIE_LEN" in
16 | 24 | 32) pass "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes" ;;
*)
fail "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes; it must be 16, 24 or 32. Generate a URL-safe unpadded value with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
;;
esac
fi
fi
fi
check_secret_key "$TLS_SECRET" tls.crt \
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
check_secret_key "$TLS_SECRET" tls.key \
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
echo
echo "== Gateway SSO configuration (deploy/$GATEWAY_DEPLOY)"
GATEWAY_ENV=$(kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" \
-o jsonpath='{range .spec.template.spec.containers[*].env[*]}{.name}={.value}{"\n"}{end}')
# env_value <NAME>: print the literal env value, or nothing when unset.
env_value() {
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print }'
}
env_is_set() {
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { found = 1 } END { exit found ? 0 : 1 }'
}
if ! env_is_set AUTH_PROXY_HEADER; then
fail "AUTH_PROXY_HEADER is not set on deploy/$GATEWAY_DEPLOY, so the gateway ignores oauth2-proxy's identity header and users are still asked for the gateway token. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
elif [ "$(env_value AUTH_PROXY_HEADER)" != "X-Forwarded-User" ]; then
fail "AUTH_PROXY_HEADER is '$(env_value AUTH_PROXY_HEADER)', expected 'X-Forwarded-User'. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
else
pass "AUTH_PROXY_HEADER=X-Forwarded-User"
fi
if ! env_is_set COOKIE_SECURE; then
fail "COOKIE_SECURE is not set on deploy/$GATEWAY_DEPLOY; the session cookie must be Secure behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
elif [ "$(env_value COOKIE_SECURE | tr '[:upper:]' '[:lower:]')" != "true" ]; then
fail "COOKIE_SECURE is '$(env_value COOKIE_SECURE)', expected 'true' behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
else
pass "COOKIE_SECURE=true"
fi
if env_is_set ALLOW_INSECURE_COOKIES; then
warn "ALLOW_INSECURE_COOKIES is still set on deploy/$GATEWAY_DEPLOY. It is ignored while COOKIE_SECURE=true but is a footgun: remove it with 'kubectl -n $NS set env deploy/$GATEWAY_DEPLOY ALLOW_INSECURE_COOKIES-'."
fi
echo
echo "== SSO objects and placeholders"
if kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" >/dev/null 2>&1; then
pass "deploy/$OAUTH_DEPLOY exists"
OAUTH_ARGS=$(kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" \
-o jsonpath='{range .spec.template.spec.containers[*].args[*]}{@}{"\n"}{end}')
else
OAUTH_ARGS=""
fail "deploy/$OAUTH_DEPLOY not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
# GitHub org restriction is the only authorization in the shipped variant.
# --email-domain=* is acceptable only because --github-org narrows membership;
# a missing or empty org value would accept any GitHub account.
if [ -n "$OAUTH_ARGS" ]; then
if printf '%s\n' "$OAUTH_ARGS" | grep -q '^--provider=github$'; then
pass "oauth2-proxy uses --provider=github"
else
fail "oauth2-proxy is not configured with --provider=github. Fix: add --provider=github to deploy/$OAUTH_DEPLOY."
fi
if printf '%s\n' "$OAUTH_ARGS" | grep -Eq '^--github-org=.+$'; then
pass "oauth2-proxy restricts membership with --github-org"
else
fail "oauth2-proxy has no non-empty --github-org. Fix: set --github-org=<your-org> in deploy/$OAUTH_DEPLOY; otherwise --email-domain=* accepts any GitHub account."
fi
fi
if kubectl -n "$NS" get httproute dsh-gateway >/dev/null 2>&1; then
pass "HTTPRoute dsh-gateway exists"
ROUTE_STATE=$(kubectl -n "$NS" get httproute dsh-gateway \
-o jsonpath='{.spec.hostnames[*]}' 2>/dev/null || true)
else
ROUTE_STATE=""
fail "HTTPRoute dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
if kubectl -n "$NS" get gateway dsh-gateway >/dev/null 2>&1; then
pass "Gateway dsh-gateway exists"
GATEWAY_STATE=$(kubectl -n "$NS" get gateway dsh-gateway \
-o jsonpath='{range .spec.listeners[*]}{.hostname}{"\n"}{end}' 2>/dev/null || true)
CERT_REFS=$(kubectl -n "$NS" get gateway dsh-gateway \
-o jsonpath='{range .spec.listeners[*].tls.certificateRefs[*]}{.namespace}{"/"}{.name}{"\n"}{end}' 2>/dev/null || true)
else
GATEWAY_STATE=""
CERT_REFS=""
fail "Gateway dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
# Placeholders are the default values in the committed manifests. They reach
# the cluster if the variant is applied before editing them.
PLACEHOLDER_RE='your-github-org|dsh-demo\.example\.com'
PLACEHOLDER_STATE=$(printf '%s\n%s\n%s\n' "$OAUTH_ARGS" "$ROUTE_STATE" "$GATEWAY_STATE")
if printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" >/dev/null 2>&1; then
fail "the applied SSO objects still contain a placeholder (your-github-org or dsh-demo.example.com):"
printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" | sed 's/^/ /' >&2
echo " Replace --github-org, --redirect-url, the Gateway listener host and the HTTPRoute hostname with the real public FQDN and organization, then re-apply and restart the proxy." >&2
else
pass "no your-github-org / dsh-demo.example.com placeholder in the applied SSO objects"
fi
# The Gateway must reference an existing TLS Secret; the default name is
# dsh-demo-tls, but 70-gateway.yaml can be edited to another name.
if [ -n "$CERT_REFS" ]; then
while IFS= read -r ref; do
[ -n "$ref" ] || continue
ref_ns=${ref%%/*}
ref_name=${ref#*/}
ref_ns=${ref_ns:-$NS}
if kubectl -n "$ref_ns" get secret "$ref_name" >/dev/null 2>&1; then
pass "Gateway TLS certificate reference ${ref_ns}/${ref_name} exists"
else
fail "Gateway references TLS Secret ${ref_ns}/${ref_name} but it does not exist. Create it (see manifests/exposure/gateway-api-sso/74-tls-secret.yaml.example)."
fi
done <<<"$CERT_REFS"
else
warn "the Gateway has no TLS certificateRefs; SSO over plain HTTP would expose the session cookie. Terminate TLS at the Gateway listener."
fi
# A second path to the gateway defeats SSO: anyone who can reach
# dsh-gateway:8080 can spoof X-Forwarded-User. This is a best-effort check.
if kubectl -n "$NS" get ingress >/dev/null 2>&1; then
INGRESS_STATE=$(kubectl -n "$NS" get ingress -o json 2>/dev/null || true)
if printf '%s' "$INGRESS_STATE" | grep -q '"name":"dsh-gateway"'; then
fail "an Ingress in namespace $NS also routes to dsh-gateway. With SSO enabled the gateway must be reachable only through oauth2-proxy: delete the Ingress variant (kubectl delete -f manifests/exposure/ingress/)."
fi
fi
echo
if [ "$FAILED" -ne 0 ]; then
echo "preflight-sso: FAILED - fix the items above before exposing the gateway." >&2
exit 1
fi
echo "preflight-sso: ok - the SSO variant looks correctly configured."

View File

@@ -48,7 +48,7 @@ kubectl -n dsh-demo create secret generic dsh-provider-keys-extra \
--from-literal=NVIDIA_API_KEY="nvapi-..." --from-literal=NVIDIA_API_KEY="nvapi-..."
# 2. The template itself (image pinned in the manifest). # 2. The template itself (image pinned in the manifest).
kubectl apply -f manifests/sandbox-template.yaml kubectl apply -f manifests/10-sandboxtemplate.yaml
# 3. A template only describes pods; a warm pool turns it into running # 3. A template only describes pods; a warm pool turns it into running
# Sandboxes and a claim adopts one. One replica is enough here. # Sandboxes and a claim adopts one. One replica is enough here.
@@ -101,6 +101,28 @@ kubectl -n dsh-demo port-forward "pod/$SB" 18081:3080
> example or a reachable Service instead; see > example or a reachable Service instead; see
> [optional hardening](../dsh-agents-as-a-service/docs/optional-hardening.md). > [optional hardening](../dsh-agents-as-a-service/docs/optional-hardening.md).
## Folder picker and the workspace
The template mounts the `dsh-home` PVC's `workspace/` subPath at
`/workspace` and sets `HOME=/workspace`. DSH's directory picker (the folder
explorer used to choose a workspace) has no start-path setting: it lists `$HOME`
when it opens, so with the image default (`/home/node`) it would start on a
directory that is not persisted. Pointing `HOME` at the PVC-backed workspace makes
the picker open there (labelled *Home* in its breadcrumbs) and makes `~` in agent
commands resolve to `/workspace`. The trade-off is that `$HOME`-relative tool
caches and config (`.cache`, `.config`, `.local`, `.npm`, `.agents`) are written
into the workspace on the PVC. DSH state is unaffected: `DSH_HOME` stays
`/home/node/.dsh`. The rationale and the list of affected paths are in
[`10-sandboxtemplate.yaml`](../dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml).
Pod specs are stamped at creation, so an existing sandbox keeps its old mounts and
`HOME` until it is recreated; the PVC retains DSH state and workspace data.
Verify inside a running sandbox:
```bash
kubectl -n dsh-demo exec "$SB" -- printenv HOME # /workspace
```
## Network policy and optional hardening ## Network policy and optional hardening
The template omits `networkPolicyManagement`, so the agent-sandbox controller The template omits `networkPolicyManagement`, so the agent-sandbox controller
@@ -114,7 +136,7 @@ NetworkPolicy are documented in
## Probes and trusted hosts ## Probes and trusted hosts
Two settings in `manifests/sandbox-template.yaml` are easy to get wrong: Two settings in `manifests/10-sandboxtemplate.yaml` are easy to get wrong:
- **Probe path.** `/` returns `401` without the launch-token cookie, so a probe - **Probe path.** `/` returns `401` without the launch-token cookie, so a probe
there crash-loops the pod. The template probes `/favicon.svg` (served there crash-loops the pod. The template probes `/favicon.svg` (served

View File

@@ -13,7 +13,7 @@
# - Optional: Secret dsh-provider-keys-extra with additional provider keys # - Optional: Secret dsh-provider-keys-extra with additional provider keys
# (OPENROUTER_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY, ...). It is # (OPENROUTER_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY, ...). It is
# referenced with `optional: true`, so it may be absent. # referenced with `optional: true`, so it may be absent.
# The agent-sandbox controller (v1.0.7, API v1beta1) must be installed. # The agent-sandbox controller (v1.0.8, API v1beta1) must be installed.
apiVersion: extensions.agents.x-k8s.io/v1beta1 apiVersion: extensions.agents.x-k8s.io/v1beta1
kind: SandboxTemplate kind: SandboxTemplate
metadata: metadata:
@@ -26,6 +26,10 @@ spec:
# quickstart reaches the sandbox through `kubectl port-forward`, which is # quickstart reaches the sandbox through `kubectl port-forward`, which is
# local to the pod. The multi-tenant example documents opt-in policies in # local to the pod. The multi-tenant example documents opt-in policies in
# examples/dsh-agents-as-a-service/docs/optional-hardening.md. # examples/dsh-agents-as-a-service/docs/optional-hardening.md.
#
# OPTIONAL: recreate a Failed sandbox Pod instead of retaining it
# (agent-sandbox v1.0.8+). Documented with the multi-tenant example.
# failedPodPolicy: Recreate
podTemplate: podTemplate:
spec: spec:
# OPTIONAL: run under gVisor. gVisor breaks `kubectl port-forward`, so # OPTIONAL: run under gVisor. gVisor breaks `kubectl port-forward`, so
@@ -52,7 +56,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
containers: containers:
- name: dsh-web - name: dsh-web
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: web - name: web
@@ -65,6 +69,21 @@ spec:
name: dsh-provider-keys-extra name: dsh-provider-keys-extra
optional: true optional: true
env: env:
# Point the OS home at the persisted agent workspace. DSH's
# directory picker (the folder explorer used to choose a workspace)
# lists $HOME when it opens and has no start-path setting, so with
# the image default (/home/node, not on a volume) it would open on a
# throwaway directory. /workspace is the PVC-backed WORKDIR, so the
# picker opens on the tree that survives suspend/resume, and `~` in
# agent commands resolves there. Trade-off: tools that keep caches
# or config under $HOME (npm/pnpm, uv, git, gh, DSH's global
# ~/.agents instructions and skills) write dotfiles onto the PVC
# inside the agent workspace. DSH_HOME is explicit
# (/home/node/.dsh), so sessions, config and credentials are
# unaffected. Rationale in full at
# examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml.
- name: HOME
value: /workspace
# Empty => DSH derives links from the request Host. Keep it empty # Empty => DSH derives links from the request Host. Keep it empty
# when fronting the sandbox with a proxy/gateway. # when fronting the sandbox with a proxy/gateway.
- name: DSH_PUBLIC_URL - name: DSH_PUBLIC_URL
@@ -77,9 +96,25 @@ spec:
# examples/dsh-agents-as-a-service/manifests/exposure/README.md. # examples/dsh-agents-as-a-service/manifests/exposure/README.md.
- name: DSH_TRUSTED_HOSTS - name: DSH_TRUSTED_HOSTS
value: "" value: ""
# DSH's own bash file sandbox (bubblewrap, else Landlock) cannot be
# enforced in this hardened pod - bubblewrap is installed but is
# unsetuid and needs an unprivileged user namespace - and fails
# closed with SANDBOX_UNAVAILABLE. The pod is the boundary here;
# keep the deployment override in sync with the multi-tenant example
# and see its README troubleshooting row to restore a confined mode.
- name: DSH_PERMISSION_MODE
value: danger-full-access
volumeMounts: volumeMounts:
- name: dsh-home - name: dsh-home
mountPath: /home/node/.dsh # DSH_HOME mountPath: /home/node/.dsh # DSH_HOME
# The agent workspace (the image's WORKDIR) is the same PVC under
# its own subPath, so DSH state - including .credentials.yaml -
# never shows up as workspace data. Kubelet creates workspace/ on
# first mount with the volume's fsGroup permissions; no init
# container is needed. See the multi-tenant template for details.
- name: dsh-home
mountPath: /workspace
subPath: workspace
# / is behind DSH's launch-token cookie (401 without it); use an # / is behind DSH's launch-token cookie (401 without it); use an
# unauthenticated path for probes. The startup probe gives a cold # unauthenticated path for probes. The startup probe gives a cold
# start (plugin activation on a small node, or the first resume of a # start (plugin activation on a small node, or the first resume of a

View File

@@ -20,6 +20,13 @@ for dir in examples/*/; do
if [ -d "${dir}manifests" ]; then if [ -d "${dir}manifests" ]; then
count=$(find "${dir}manifests" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ') count=$(find "${dir}manifests" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')
[ "$count" -gt 0 ] || err "$name: manifests/ has no top-level *.yaml files" [ "$count" -gt 0 ] || err "$name: manifests/ has no top-level *.yaml files"
for manifest in "${dir}"manifests/*.yaml; do
[ -e "$manifest" ] || continue
case "$(basename "$manifest")" in
[0-9][0-9]-*.yaml) ;;
*) err "$name: top-level manifest $(basename "$manifest") must be named NN-*.yaml" ;;
esac
done
fi fi
stray=$(find "$dir" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ') stray=$(find "$dir" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')

View File

@@ -22,22 +22,60 @@ expect() { # <file> <literal>
# Main Dockerfile build args # Main Dockerfile build args
expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION" expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION"
expect Dockerfile "ARG DSH_COMMIT=$DSH_COMMIT"
expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION" expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION"
expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION" expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION"
expect Dockerfile "ARG NODE_BUILD_IMAGE=$NODE_BUILD_IMAGE"
expect Dockerfile "ARG NODE_RUNTIME_IMAGE=$NODE_RUNTIME_IMAGE"
# The build stage must fail closed when the tag no longer resolves to DSH_COMMIT.
expect Dockerfile "git -C /src rev-parse HEAD"
# Example gateway build base image
expect examples/dsh-agents-as-a-service/gateway/Dockerfile \
"ARG GATEWAY_PYTHON_IMAGE=$GATEWAY_PYTHON_IMAGE"
# Local configuration mirrored for humans # Local configuration mirrored for humans
expect .env.sample "DSH_VERSION=$DSH_VERSION" expect .env.sample "DSH_VERSION=$DSH_VERSION"
expect .env.sample "DSH_COMMIT=$DSH_COMMIT"
expect .env.sample "PNPM_VERSION=$PNPM_VERSION" expect .env.sample "PNPM_VERSION=$PNPM_VERSION"
expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION" expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION"
# Human-facing docs embed the resolved commit in the build example and table.
expect docs/main-image.md "$DSH_COMMIT"
expect docs/versioning.md "$DSH_COMMIT"
# Every container image is pinned to an immutable multi-arch index digest, not
# a mutable tag. This is a supply-chain invariant: don't "fix" a failing check
# by dropping the digest.
for image_var in NODE_BUILD_IMAGE NODE_RUNTIME_IMAGE GATEWAY_PYTHON_IMAGE OAUTH2_PROXY_IMAGE; do
image_value="${!image_var:-}"
if ! printf '%s' "$image_value" | grep -qE '@sha256:[0-9a-f]{64}$'; then
err "versions.env: $image_var must be digest-pinned (name:tag@sha256:<64 hex>), got: ${image_value:-<unset>}"
fi
done
if ! printf '%s' "${DSH_COMMIT:-}" | grep -qE '^[0-9a-f]{40}$'; then
err "versions.env: DSH_COMMIT must be a full 40-character commit SHA, got: ${DSH_COMMIT:-<unset>}"
fi
# Build entrypoints must pass DSH_COMMIT rather than trusting the tag alone.
expect Makefile "--build-arg DSH_COMMIT=\$(DSH_COMMIT)"
for wf in build-main.yaml release.yaml ci-image.yaml; do
expect ".github/workflows/$wf" "DSH_COMMIT="
done
# Image references in the example manifests # Image references in the example manifests
for f in \ for f in \
examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \ examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \
examples/minimal-single-tenant/manifests/sandbox-template.yaml; do examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml; do
expect "$f" "image: $GHCR_IMAGE:$IDENTITY" expect "$f" "image: $GHCR_IMAGE:$IDENTITY"
done done
expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY" expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY"
# The oauth2-proxy exposure variant pins the digest, not the mutable tag.
expect examples/dsh-agents-as-a-service/manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml \
"image: $OAUTH2_PROXY_IMAGE"
# The gateway image must live below the repository image (one package per # The gateway image must live below the repository image (one package per
# example component, no unrelated top-level packages). # example component, no unrelated top-level packages).
case "$GATEWAY_IMAGE" in case "$GATEWAY_IMAGE" in

View File

@@ -3,7 +3,12 @@
# "make check". # "make check".
# #
# ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision # ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision
# ./scripts/set-version.sh dsh-v0.2.1-rc.3 r3 # same ref, new repo revision # ./scripts/set-version.sh dsh-v0.2.1-rc.5-r6 r3 # same ref, new repo revision
#
# For a new DSH ref the script resolves the tag's commit with "git ls-remote"
# (peeled ^{} commit first, tag ref as fallback) and rewrites DSH_COMMIT in
# versions.env, the Dockerfile ARG default and .env.sample. A revision-only bump
# at the same ref keeps the existing commit and needs no network access.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -23,9 +28,11 @@ if [ -n "$new_revision" ]; then
fi fi
# shellcheck source=/dev/null # shellcheck source=/dev/null
old_version="$(. ./versions.env; printf '%s' "$DSH_VERSION")" . ./versions.env
# shellcheck source=/dev/null
old_revision="$(. ./versions.env; printf '%s' "${RELEASE_REVISION:-}")" old_version="$DSH_VERSION"
old_revision="${RELEASE_REVISION:-}"
old_commit="${DSH_COMMIT:-}"
old_identity="$old_version" old_identity="$old_version"
[ -z "$old_revision" ] || old_identity="$old_version-$old_revision" [ -z "$old_revision" ] || old_identity="$old_version-$old_revision"
@@ -37,6 +44,29 @@ if [ "$old_identity" = "$new_identity" ]; then
exit 0 exit 0
fi fi
# Resolve the commit the new upstream tag points at. Annotated tags expose a
# peeled ^{} line (the commit) and a tag-object line; prefer the peeled commit
# and fall back to the tag ref for lightweight tags. A repository-only revision
# bump keeps the already pinned commit, so it needs no network.
new_commit="$old_commit"
if [ "$new_version" != "$old_version" ] || [ -z "$new_commit" ]; then
if ! refs="$(git ls-remote --tags --exit-code "$DSH_REPO" \
"refs/tags/$new_version^{}" "refs/tags/$new_version")"; then
echo "set-version: upstream tag '$new_version' not found in $DSH_REPO" >&2
exit 1
fi
new_commit="$(printf '%s\n' "$refs" \
| awk -v t="refs/tags/$new_version^{}" '$2 == t {print $1}' | head -n1)"
if [ -z "$new_commit" ]; then
new_commit="$(printf '%s\n' "$refs" \
| awk -v t="refs/tags/$new_version" '$2 == t {print $1}' | head -n1)"
fi
if ! printf '%s' "$new_commit" | grep -qE '^[0-9a-f]{40}$'; then
echo "set-version: could not resolve a commit for '$new_version' in $DSH_REPO" >&2
exit 1
fi
fi
# Replace the identity first (it contains the base version), then the base # Replace the identity first (it contains the base version), then the base
# version itself. CHANGELOG.md is generated from tags, so it is left alone. # version itself. CHANGELOG.md is generated from tags, so it is left alone.
while IFS= read -r file; do while IFS= read -r file; do
@@ -48,17 +78,30 @@ while IFS= read -r file; do
sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file" sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file"
done < <(git ls-files) done < <(git ls-files)
# versions.env is authoritative: write both keys explicitly so an empty # versions.env is authoritative: write these keys explicitly so an empty
# revision is stored as an empty value. # revision is stored as an empty value.
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" versions.env
# Values stored separately from the full identity: Dockerfile ARG defaults and # Values stored separately from the full identity: Dockerfile ARG defaults and
# the human-facing sample cannot be reached by the identity replacement above. # the human-facing sample cannot be reached by the identity replacement above.
sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile
sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile
sed -i "s/^ARG DSH_COMMIT=.*/ARG DSH_COMMIT=$new_commit/" Dockerfile
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" .env.sample
# The human-facing build examples embed the full SHA, which the version
# replacement above cannot see. Keep them in step; check-versions.sh verifies.
if [ -n "$old_commit" ] && [ "$old_commit" != "$new_commit" ]; then
for doc in docs/main-image.md docs/versioning.md; do
[ -f "$doc" ] || continue
sed -i "s/$old_commit/$new_commit/g" "$doc"
done
fi
echo "set-version: $old_identity -> $new_identity" echo "set-version: $old_identity -> $new_identity"
echo "set-version: DSH_COMMIT=$new_commit"
echo "set-version: run 'make check' and review the diff" echo "set-version: run 'make check' and review the diff"

View File

@@ -2,23 +2,48 @@
# repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub # repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub
# Actions workflows. Never hard-code these values elsewhere: run "make check" # Actions workflows. Never hard-code these values elsewhere: run "make check"
# (scripts/check-versions.sh) to verify every pin. # (scripts/check-versions.sh) to verify every pin.
#
# Every container image below is pinned to an immutable multi-arch index digest
# (name:tag@sha256:...), not just a mutable tag. Bump the tag and the digest
# together; scripts/check-versions.sh requires the digest form.
# Upstream DeepSeek Harness source, and the ref this repository packages. # Upstream DeepSeek Harness source, and the ref this repository packages.
# DSH_COMMIT is the full commit SHA that DSH_VERSION resolves to upstream; the
# Dockerfile verifies the clone's HEAD against it and fails the build on drift.
DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
DSH_VERSION=dsh-v0.2.1-rc.3 DSH_VERSION=dsh-v0.2.1-rc.5
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
PNPM_VERSION=11.28.5 PNPM_VERSION=11.28.5
# Release identity = DSH_VERSION plus an optional repository revision. # Release identity = DSH_VERSION plus an optional repository revision.
# An empty RELEASE_REVISION is the first release at this DSH ref; rN (N >= 2) # An empty RELEASE_REVISION is the first release at this DSH ref; rN (N >= 2)
# marks a repository-only re-release at the same DSH ref. The dsh-v0.2.1-rc.3 # marks a repository-only re-release at the same DSH ref. This is the first
# image tag is already published on GHCR and tags are immutable, so the current # release at dsh-v0.2.1-rc.5-r6, so the identity is the plain tag; a later
# scheme started at -r2; the current identity is dsh-v0.2.1-rc.3-r4. # repository-only change at the same ref becomes -r2.
RELEASE_REVISION=r4 RELEASE_REVISION=r6
# Base images for the main DSH Web image (root Dockerfile), pinned by digest.
NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
# Base image for the example gateway (examples/dsh-agents-as-a-service/gateway).
GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Base images for the main DSH Web image (root Dockerfile), pinned by digest.
NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
# Base image for the example gateway (examples/dsh-agents-as-a-service/gateway).
GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Control-plane versions used by the examples and their manifests. # Control-plane versions used by the examples and their manifests.
AGENT_SANDBOX_VERSION=v1.0.7 # The matching SHA-256s pin the release manifests that CI downloads and
# applies; update both the version and the digest when bumping either pin.
AGENT_SANDBOX_VERSION=v1.0.9
AGENT_SANDBOX_MANIFEST_SHA256=4bf9ee5558e9a663f91cfa5963e96f2591bfaa1dcf79f03bfab65bc5d65cc699
GATEWAY_API_VERSION=v1.2.1 GATEWAY_API_VERSION=v1.2.1
OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 GATEWAY_API_MANIFEST_SHA256=97598bf6ab3b33b9b5c5432bdd24de091e4e9c3aa0575ebb0710a2a19cd64d64
OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
# Registry layout: the main image is the repository itself, and every example # Registry layout: the main image is the repository itself, and every example
# component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>). # component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>).