/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>
* 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>
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>
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>
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>
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>
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>
Bump RELEASE_REVISION to r4 for the optional-hardening feature release and refresh every consuming manifest/doc. CHANGELOG.md is generated by the release workflow and is intentionally left untouched.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Add opt-in hardening to the agents-as-a-service example:
- commented runtimeClassName/nodeSelector fields for sandboxes and Deployments
- optional sandbox NetworkPolicy plus exposure-variant policies
- optional dsh-provider-keys-extra Secret for extra provider keys
- docs/optional-hardening.md with verification, caveats and rollback
The base walkthrough stays portable; no gateway code, routes or auth changed.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Add the project-local dsh-agent-sandbox-platform skill covering platform operations, the optional agent-sandbox MCP server, feature-extension recipes, and a read-only preflight. Document project skills in README, docs and AGENTS.md.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Updated the release workflow to source environment variables from versions.env and modified the tag comparison logic.
Signed-off-by: Nicolás Georger <info@sredevops.org>
Update the script to read environment variables from versions.env using 'source' instead of a loop.
Signed-off-by: Nicolás Georger <info@sredevops.org>
* feat(repo)!: restructure around the dsh-harness image and one example per implementation
- the root Dockerfile is the primary artifact; versions.env is the single source of truth
- merge gateway-api/dsh-demo into examples/dsh-agents-as-a-service (manifests/, exposure variants)
- move the single-tenant template into examples/minimal-single-tenant
- publish the gateway as a nested package under the repository image
- mirror the DSH ref for release identities (first release dsh-v0.2.1-rc.2-r2)
- CI: lint/python/manifests/gateway-image, reusable multi-arch build, tag-driven release, weekly e2e
- docs: README, docs/{main-image,examples,gitflow,versioning}, example READMEs, AGENTS.md, governance
- SSO is opt-in again; gateway pull policy and cookie defaults match the documented demo
BREAKING CHANGE: example manifest paths, the gateway image name, and the removal
of deploy/ and gateway-api/ affect existing deployments; see the migration table
in README.md.
* docs(changelog): generate initial changelog from history
* fix(scripts): make set-version update Dockerfile and sample revision pins
* build(hadolint): document intentional lint exceptions for the main image
---------
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Updated imagePullPolicy to always pull the latest image and increased timeout values for IDLE_TIMEOUT and WAKE_TIMEOUT.
Signed-off-by: Nicolás Georger <info@sredevops.org>
After oauth2-proxy authentication, the gateway now renders a minimal SSO
landing (no signup/token forms) that auto-connects to the sandbox and offers
a single 'Recreate sandbox' action for a clean start.
- _sso_session: stop blocking on wait_ready (provisioning is non-blocking;
the proxy path still awaits readiness), so the landing reports
'Provisioning' instead of stalling for WAKE_TIMEOUT
- landing: SSO branch renders a minimal page (signed-in user + sandbox state
+ auto-connect meta-refresh), replacing the generic signup/token forms
- new POST /recreate: deletes the claim (cascade pod+PVC), waits out
finalizers, re-provisions, and reconnects; CSRF-protected, SSO-only
- tests: 109 passing (SSO landing provision/connect/recreate cases)
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
oauth2-proxy base64-decodes cookie_secret and requires it to decode to 16/24/32
bytes. Plain 'openssl rand -base64 32' yields 44 chars of standard base64
(padding + possibly '+/'), which it cannot decode and treats as 44 raw bytes,
failing with 'cookie_secret must be 16, 24, or 32 bytes ... but is 44 bytes'.
Use URL-safe, unpadded base64 instead (secrets.token_urlsafe(32) or the
openssl|tr variant).
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Add Gateway and HTTPRoute manifests to route external traffic to the
dsh-gateway service (dsh-demo:8080).
- Gateway dsh-gateway: HTTPS listener with TLS termination against a
dsh-demo-tls cert secret.
- HTTPRoute dsh-gateway: hostname -> dsh-gateway:8080.
- Example TLS secret manifest + README with apply/verify/rollback.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
DSH binds its auth cookie to the request authority (payload.authority ===
authority) and looks the cookie up under a name derived from the Host. The
upgrade presents Host = pod-IP:3080 (websockets derives it from the URI), so a
cookie minted for the browser authority is rejected with 401. The previous
pod-IP change fixed the Host fence (403) but not this, so the client kept
looping on "[connection] connection lost, retry #N".
Perform the WS handshake under the pod-IP authority too, so the cookie matches
the Host the upgrade carries. The HTTP path keeps the browser authority.
Also serialise the lazy Kubernetes client init: anyio.to_thread can enter it
concurrently, and a thread that observes _crd set while _core is still None
returns a None client. The live log showed the result:
AttributeError: 'NoneType' object has no attribute 'read_namespaced_pod'
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
DSH's Host fence (isTrustedApiRequest) only trusts loopback, an allow-listed
host, or its own bind address, and requires any Origin to match the Host
authority. The gateway forwarded the browser Host as an additional header,
but websockets derives Host from the URI and cannot be overridden, so the
request carried a duplicate/mismatched Host plus the browser Origin and DSH
answered 403. The gateway then closed with 1011, which the DSH client retries
forever:
[connection] connection lost, retry #1 ... #55
Dial the pod IP instead - it is the bind address, so the derived Host passes
the fence - and forward no Origin; CSWSH is already rejected by same_origin()
before the upstream connection. Fall back to the service FQDN when
status.podIPs is empty and bracket IPv6 literals.
Reproduced against dsh-v0.2.1-rc.2: pod-IP upgrade with no Origin opens;
the same upgrade carrying the browser Origin gets HTTP 403.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Every proxied response is streamed through _relay using one shared
httpx.AsyncClient configured with timeout=HTTP_TIMEOUT (300s). An idle SSE
or long-poll stream therefore raised httpx.ReadTimeout every five minutes,
which escaped the StreamingResponse as a full traceback and truncated the
browser's stream.
Build proxied requests with a timeout that leaves read unbounded (connect,
write and pool stay bounded), and end the relay quietly on httpx.HTTPError
instead of propagating it. The relay is cancelled when the browser
disconnects and _relay closes the upstream response, so an unbounded read
cannot outlive the client.
Docs: note the split in the HTTP_TIMEOUT row.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
filtered_query() round-tripped the query through parse_qsl/urlencode, which
percent-encoded DSH's Vite-style combined-module URLs. The client asks for
"plugins/??@deepseek-ai/dsh-client-modules/client.js&rev=..." - the raw query
itself starts with "?" - so the gateway sent "%3F%40deepseek-ai..." and the
upstream answered 404. The browser then failed the boot with:
client-modules: HTML did not preload
@deepseek-ai/dsh-client-modules/client.js
Strip only token parameters and keep every other raw part untouched. Add
regression tests for the combined-module URL and an encoded token key.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
get_pod() returned kubernetes' V1Pod model, but dsh_cookie() indexes it as a
dict (pod.get("metadata")), so every gateway handshake crashed with:
AttributeError: 'V1Pod' object has no attribute 'get'
and every proxied request answered 500. The unit tests inject a fake that
returns dicts, so the mismatch only appeared against a real API server.
Normalise at the adapter boundary with ApiClient().sanitize_for_serialization
and cover it with a test that returns a real V1Pod.
Also document digest-pinned gateway rollouts: the workflow publishes the
mutable :gateway tag, and imagePullPolicy: IfNotPresent keeps the cached old
image across kubectl apply / rollout restart.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
DSH takes tens of seconds to load plugins and start serving on a small
node, and every resume starts cold. The liveness budget (initialDelay
15s, period 15s, failureThreshold 3) is shorter than that, so the kubelet
killed the container mid-startup. DSH exits 0 on SIGTERM, which the
sandbox controller records as PodSucceeded/Finished - the pod is then
never recreated and the sandbox stays unusable until a manual
suspend/resume cycle.
Gate liveness and readiness behind a startupProbe on /favicon.svg with a
120s budget (24 x 5s, matching the gateway's WAKE_TIMEOUT). Liveness still
catches a genuinely hung server once startup succeeds.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Resuming a sandbox recreates its pod on the same PVC. The kubelet's default
fsGroup policy ("Always") recursively ORs 0660 into every file on each pod
start, so DSH's .credentials.yaml — created 0600 — comes back as 0660. The
credentials plugin refuses to boot in that case:
credentials-local: .../.credentials.yaml is readable beyond its owner
(mode 660); run "chmod 600 ..."
That makes the first resume after a suspend crash-loop, which breaks the
example's step 6 and any gateway wake-on-connect.
Set fsGroupChangePolicy: OnRootMismatch on both sandbox templates. The
kubelet then skips the recursive walk when the volume root already carries
the fsGroup ownership/mode applied on the first mount — exactly the
suspend/resume case, while a fresh PVC is still chowned correctly.
Document the upgrade path: Sandbox objects snapshot their pod template, so
existing sandboxes and warm-pool members need the policy patched (plus a
one-time chmod 600) or must be recreated.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
The sweeper listed every SandboxClaim in NAMESPACE and treated each
dsh-<user> name as one of its own users. A claim applied by hand from
the resource-layer manifests (e.g. 30-claim-alice.yaml) therefore looked
exactly like a gateway user: the idle clock started on first sighting
and the pod was suspended after IDLE_TIMEOUT. That deletes the pod the
README's step 3 logs/port-forward targets, so the walkthrough broke as
soon as the gateway was deployed next to it.
Mark claims created by create_claim() with
aaas.example.com/managed-by=dsh-gateway and skip everything else in
sweep_once() before any idle bookkeeping happens. Document the
ownership boundary in the example docs, the manifest and ADR-5, add
unit coverage for the marker and the skipped sweep, and assert in
run-test-kind.sh that alice's hand-applied claim stays Running while
bob is correctly idle-suspended.
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
* Rewrite DSH gateway for browser sessions, HTTP and WebSocket
- Replace the Flask proxy with a Starlette ASGI app (uvicorn factory),
keeping the control plane (claims, wake/suspend, idle sweeper).
- Add HttpOnly gateway sessions and a server-side DSH launch-token
handshake: read the token from pod logs, exchange it for a dsh-auth
cookie, cache per (user, pod UID, Host), refresh once on 401.
- Proxy HTTP/SSE and WebSocket (/api/remote.mux) under /u/<user>/, with
header stripping/injection, Location rewriting and wake-on-connect.
- Add a landing/signup/login page, health endpoint, pinned dependencies,
a read-only-rootfs gateway image, 87 cluster-free tests and docs.
* Fix DSH probe crash-loop and harden gateway manifests
- Probe /favicon.svg instead of /: DSH gates / behind its launch-token
cookie and returns 401, which made warm-pool pods crash-loop.
- Pass DSH_TRUSTED_HOSTS through the entrypoint as --trusted-host args
for public/ingress hosts; keep localhost trusted.
- Deploy the published :gateway image, add pods/pods-log RBAC, /healthz
probes, read-only rootfs with a /tmp emptyDir and non-root security.
- Add optional Traefik Ingress and document the prerequisites.
* Update CI, smoke tests, build tooling and docs
- Run gateway pytest/ruff and build/smoke-test the image in CI.
- Parameterize the DSH build version and pnpm version inputs.
- Exercise cookie signup, HTML proxy, WebSocket auth, suspend/wake and
delete in run-test-kind.sh.
- Add Makefile/build.sh targets and document the architecture, handshake,
probes, trusted hosts, troubleshooting and limitations.
---------
Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
Added a step to create a manifest list for Docker images built for different platforms without annotations if the previous step fails.
Signed-off-by: Nicolás Georger <info@sredevops.org>
- Enhanced Dockerfile with updated image labels and source.
- Revised README to reflect changes in DSH Web architecture and deployment instructions.
- Added Kubernetes sandbox template for DSH Web with security context and environment variables.
- Updated .env.sample and CODEOWNERS for consistency.
- Introduced multi-arch Docker build workflow for improved CI/CD.