1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 01:13:24 +00:00
Files
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

28 KiB

Architecture

This document describes the browser-usable agents-as-a-service platform in this example (README, manifests, gateway): how the gateway, 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, and one DSH Web pod + PVC per user. The cluster is assumed to run agent-sandbox with the pinned AGENT_SANDBOX_VERSION from versions.env and the extensions (SandboxTemplate, SandboxWarmPool, SandboxClaim).

Container view

flowchart LR
    browser["Browser"]

    subgraph cluster["Kubernetes cluster - namespace dsh-demo"]
        gw["Gateway Deployment<br/>Starlette ASGI on uvicorn :8080<br/>landing page + auth + proxy + sweeper"]
        api["Kubernetes API Server"]
        ctrl["agent-sandbox controller"]
        pool["SandboxWarmPool dsh-pool<br/>2 pre-warmed spares"]
        claim["SandboxClaim dsh-USER<br/>one per user"]
        sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"]
        svc["Headless Service<br/>status.serviceFQDN"]
        pod["DSH Web pod<br/>node process :3080"]
        pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh + /workspace (subPath)")]
    end

    browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw
    gw -->|"claims, sandboxes, pods, pod logs"| api
    api --> ctrl
    ctrl -->|"adopt / suspend / resume"| claim
    claim --> sb
    pool -.->|"replenishes"| sb
    sb --> svc
    svc --> pod
    pod --> pvc

Responsibilities:

Component Responsibility
Browser Holds only the gateway session cookie (dsh_gw_session). Talks exclusively to the gateway.
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.
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, 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.

Exposure variants

The base manifests deploy the gateway as a ClusterIP Service (dsh-gateway:8080) and expose nothing outside the cluster. Two mutually exclusive variants live under manifests/exposure/:

Variant Entry point Authentication Notes
Ingress Traefik Ingress + optional TLS the gateway's own signup/session cookie simplest; no external IdP; set COOKIE_SECURE=true with HTTPS
Gateway API + SSO Gateway API Gateway/HTTPRoute terminating TLS GitHub OAuth via oauth2-proxy, identity forwarded as X-Forwarded-User requires AUTH_PROXY_HEADER on the gateway; the trust boundary applies

They are alternatives, not a stack: applying both creates a second path to the same gateway and defeats SSO. When SSO is enabled, the Service must stay ClusterIP and be reachable only through oauth2-proxy — otherwise any client can send X-Forwarded-User itself and be treated as that user. The gateway ignores the header unless AUTH_PROXY_HEADER is set. See the exposure README and the gateway README.

Each variant also ships a NetworkPolicy: ingress/61-networkpolicy.yaml limits gateway ingress 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 DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic enforcement. These policies are required for production: exposing the gateway by applying 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.

Flows

Signup (browser)

sequenceDiagram
    autonumber
    actor B as Browser
    participant G as Gateway
    participant K as Kubernetes API
    participant C as agent-sandbox controller

    B->>G: POST /users (form: user=bob, invite, Origin)
    G->>G: rate limit, invite check, validate user name and body size
    G->>K: create SandboxClaim dsh-bob (warmPoolRef)
    K->>C: reconcile claim
    C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
    G->>K: read claim (status) + sandbox + pod
    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)

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 means the user already has a claim, in which case POST /login (bearer token) or the existing 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)

sequenceDiagram
    autonumber
    actor B as Browser
    participant G as Gateway
    participant K as Kubernetes API
    participant D as DSH Web pod

    B->>G: GET /u/bob/ (Cookie dsh_gw_session=<sid>)
    G->>G: look up sid -> session (valid, not expired), user = bob
    G->>K: resolve claim -> sandbox -> pod
    alt sandbox Suspended
        G->>K: patch operatingMode=Running (wake)
        loop until Ready or WAKE_TIMEOUT
            G->>K: read sandbox Ready condition
        end
    end
    G->>K: read pod logs (tail)
    K-->>G: "dsh web: ...?token=..." line
    G->>D: GET /?token=... (browser Host, X-Forwarded-*)
    D-->>G: 303 + Set-Cookie dsh-auth-*
    G->>G: cache cookie for (user, pod UID, Host)
    G->>G: strip Cookie/Authorization/hop-by-hop headers
    G->>D: replay request with dsh-auth cookie
    D-->>G: 200 HTML / SSE stream
    G-->>B: 200 (upstream Set-Cookie dropped, Location rewritten)

Handshake cache behavior: one entry per (user, pod UID, Host); refreshed once when DSH answers 401; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth 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

sequenceDiagram
    autonumber
    participant S as Sweeper - lifespan task
    participant G as Gateway
    participant K as Kubernetes API
    actor B as Browser

    S->>G: no proxied request for IDLE_TIMEOUT
    G->>K: patch sandbox operatingMode=Suspended
    K-->>G: pod deleted, PVC + Service retained
    Note over S,G: in-flight requests/WebSockets are tracked,<br/>so a busy turn is never suspended
    B->>G: GET /u/bob/ after a while
    G->>K: patch operatingMode=Running
    G->>K: wait for Ready (WAKE_TIMEOUT)
    G-->>B: request held, then served

Suspend deletes only the pod. The PVC keeps conversations, sessions, config and the agent 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 example's resource-layer walkthrough applies dsh-alice by hand, and every claim is named dsh-<user>. Claims the gateway creates carry aaas.example.com/managed-by=dsh-gateway; the sweeper skips everything else, so it can never suspend a sandbox an operator is using. Without that marker the sweeper would suspend a hand-applied claim 60s after first sighting and break kubectl logs/port-forward against its pod.

Resume and file ownership. The pod template sets fsGroupChangePolicy: OnRootMismatch on purpose. DSH's credentials plugin refuses to boot when .credentials.yaml has any group/other permission bit (it creates the file 0600). The kubelet's default fsGroup policy (Always) recursively ORs 0660 into every file on each pod start, so the first resume of a sandbox would crash-loop with mode 660. OnRootMismatch only rewrites ownership/permissions when the volume root does not already match the fsGroup, which it does after the first mount — so suspend/resume keeps working. Sandboxes snapshot their pod template, so an updated SandboxTemplate does not retroactively change existing ones.

WebSocket

sequenceDiagram
    autonumber
    actor B as Browser
    participant G as Gateway
    participant D as DSH Web pod

    B->>G: GET /u/bob/... Upgrade: websocket (Cookie dsh_gw_session)
    G->>G: authenticate + mark user in-flight
    alt cookie missing/invalid
        G-->>B: 101 upgrade, then close 4401
    else authenticated
        G->>D: websockets.connect(path, Host, Cookie dsh-auth)
        alt upstream handshake fails
            G-->>B: 101 upgrade, then close 1011
        else bridged
            G-->>B: 101 Switching Protocols
            loop frames
                B->>D: frames proxied through G
                D->>B: frames proxied through G
            end
            G->>G: clear in-flight on close
        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

ADR-1: Starlette ASGI instead of a sync Flask proxy

  • Decision: implement the gateway as a Starlette app served by uvicorn gateway:create_app --factory.
  • Why: the browser needs WebSocket upgrades, streaming SSE responses, and concurrent long-poll wake requests. A requests-based Flask proxy cannot upgrade connections and blocks workers while a sandbox wakes.
  • Cost: async code paths and explicit bridging for the sync kubernetes client (offloaded to threads). Accepted: one small service, one framework.

ADR-2: Path-based routing (/u/{user}/) instead of per-user subdomains

  • Decision: serve each user under https://gateway/u/{user}/.
  • Why: one hostname, one TLS certificate, no wildcard DNS or per-user Ingress objects. DSH uses relative asset/API URLs and injects <base href="./">, so a path prefix works; the gateway rewrites absolute-path Location headers and redirects /u/{user} to /u/{user}/.
  • Cost: any upstream code that emits root-absolute URLs would need rewriting; cookie domains are shared (mitigated by stripping DSH Set-Cookie). If DSH ever changes its base handling, per-user subdomains are the fallback.

ADR-3: Server-side launch-token handshake instead of exposing the token

  • Decision: the gateway reads DSH's launch token from pod logs, performs the token handshake itself, caches DSH's auth cookie, and authenticates the browser with its own HttpOnly cookie.
  • Why: DSH has no token API and returns 401 on / without the launch-token cookie. Handing the token to the browser would put it in URLs, history, and logs, and would let users bypass gateway auth.
  • Cost: log reading permission and a brittle parser; see Upstream feature request. This is the least durable part of the design and the first thing to replace.

ADR-4: In-memory state, single replica

  • Decision: idle clocks, handshake cache, and in-flight tracking live in gateway memory; the Deployment runs one replica.
  • Why: the simplest thing that works, with no extra datastore for a demo/reference platform.
  • Cost: a restart forgets idle timers (sandboxes stay Running until their next request) and the handshake cache; two replicas would double-suspend and race. Scaling out requires moving this state to a shared store (Redis/Postgres) or leader election. Documented instead of hidden.

ADR-5: Claims as the user record

  • Decision: a user is a SandboxClaim named dsh-<user>; the bearer-token SHA-256 lives in a claim annotation, and the gateway marks its own claims with aaas.example.com/managed-by=dsh-gateway.
  • Why: no database, no separate account service; deleting the claim is account deletion and cascades everything. The ownership annotation keeps a namespaced claim list safe to share with objects created by hand or by other controllers.
  • Cost: no password reset, no admin API, no audit log of its own; Kubernetes API load grows with users. Claims created before the ownership marker existed must be annotated (kubectl annotate sandboxclaim ...) to keep being swept. Fine for a reference pattern, not for a product.
  • Decision: browsers get dsh_gw_session=<sid>, HttpOnly, SameSite=Lax (Secure when enabled); the sid maps to Session(user, expires_at) in gateway memory. API clients may still use Authorization: Bearer. The cookie name is __Host-dsh_gw_session when the Secure flag is on and no explicit COOKIE_NAME is set.
  • Why: tokens never appear in URLs or JS-readable storage, the cookie value is useless if 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

The gateway runs with a namespace-scoped ServiceAccount (manifests/50-gateway.yaml):

API group Resource Verbs Why
extensions.agents.x-k8s.io sandboxclaims get, list, create, delete signup, state, account deletion
agents.x-k8s.io sandboxes get, list, patch resolve DNS/pod, wake/suspend
"" (core) pods get, list resolve the pod behind a sandbox
"" (core) pods/log get launch-token handshake

Notes and limits:

  • Pod logs are sensitive. They contain the DSH launch token and possibly model output. The 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.
  • No cluster-wide access, no secret reads. The gateway does not read dsh-provider-keys. (It does read the optional signup invite code if it is mounted as an env var.)
  • Browser-facing hardening: opaque server-side sessions instead of token-bearing cookies, HttpOnly/SameSite=Lax/optional Secure (+ __Host- prefix when secure), 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, so every public hostname must be in DSH_TRUSTED_HOSTS; localhost/127.0.0.1 are always trusted.
  • TLS is external. The gateway serves plain HTTP; terminate TLS at the Ingress and set 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, and the shared provider keys (dsh-provider-keys plus the optional dsh-provider-keys-extra). The base example is demo-only: production requires gVisor for the sandboxes, the sandbox NetworkPolicy, the gateway egress policy and the exposure NetworkPolicy, plus per-tenant credentials or an egress proxy. See the production checklist.
  • DSH's inner file sandbox is disabled by deployment override. The template sets DSH_PERMISSION_MODE=danger-full-access because the in-container backends are unusable here: bubblewrap ships in the image but is unsetuid and cannot create an unprivileged user namespace under the pod's seccomp/capability surface, and no Landlock runner is available; the confined 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. 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 after applying it.

Failure modes

Failure Symptom Behavior / mitigation
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)
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 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
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
Optional NetworkPolicy blocks DNS/probes Sandbox 502/503, pod restarts, failed getent Check the CNI, DNS selector/NodeLocal address and kubelet-probe traffic; see the hardening doc
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
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

DSH should expose the launch token as a first-class, machine-readable interface so platforms do not need pods/log access. Proposed shapes (any one suffices):

  1. Token file: dsh web --token-file /run/dsh/token writes the current launch token (mode 0600) and rewrites it on rotation.
  2. Environment input: accept DSH_LAUNCH_TOKEN (platform-generated) so the token never has to be discovered.
  3. Loopback/trusted-host endpoint: GET /api/auth/launch-token that only answers on the pod's loopback or a trusted host and returns the token plus expiry.
  4. Token in a well-known file under DSH_HOME with the same semantics as (1).

Why this matters: log scraping is brittle (rotation, restart, format changes), requires a broad pods/log permission that also exposes application output, and can leak tokens into log pipelines. Until an upstream API exists, the gateway's parser, DSH_LOG_TAIL_LINES, and the single-retry refresh are the supported workaround.

Evolution path

  1. Replace log scraping with the upstream token API (above), deleting the pods/log permission.
  2. Move idle clocks, in-flight tracking, handshake cache and sessions to a shared store; then 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.
  4. Add NetworkPolicies (gateway → sandboxes only, plus the documented gateway egress policy), 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 per-user subdomains with wildcard DNS/TLS.