* 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>
8.7 KiB
Platform runbook
Scope: the resource layer and gateway in
examples/dsh-agents-as-a-service/, one namespace, one DSH Web sandbox per
user. Read the example README and AGENTS.md first. All commands are read-only
unless a section is explicitly a change; confirm before changing anything.
1. Context, version, CRDs
kubectl config current-context
kubectl auth whoami
kubectl config view --minify -o jsonpath='{..namespace}{"\n"}'
kubectl get crd | grep -E 'agents\.x-k8s\.io'
kubectl -n agent-sandbox-system get deploy agent-sandbox-controller \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
Compare the controller image tag with AGENT_SANDBOX_VERSION in
versions.env. The example needs the extension CRDs plus core sandboxes;
a missing CRD is a prerequisite failure, not an application bug.
2. Resource model
| Object | Role | Key fields |
|---|---|---|
SandboxTemplate |
Blueprint; pinned image, probes, security context, PVC template | envVarsInjectionPolicy, volumeClaimTemplatesPolicy, service, podTemplate |
SandboxWarmPool |
Pre-warmed spares from one template | spec.replicas, sandboxTemplateRef |
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 |
Pod |
The DSH Web process | :3080, PVC at /home/node/.dsh and /workspace (subPath: workspace) |
PersistentVolumeClaim |
Per-sandbox DSH state + agent workspace | dsh-home-<sandbox> |
Service |
Stable per-sandbox DNS | <sandbox>, status.serviceFQDN |
Delete a claim to cascade-delete its sandbox and PVC (account deletion). Never delete a PVC or namespace without an explicit, named confirmation.
3. Read-only inspection
kubectl -n "$NS" get sandboxtemplates,sandboxwarmpools,sandboxclaims,sandboxes -o wide
kubectl -n "$NS" get pods,pvc,svc -o wide
kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -30
kubectl -n "$NS" get sandboxclaim "$CLAIM" -o yaml
kubectl -n "$NS" get sandbox "$SB" -o yaml
Bounded logs: kubectl -n "$NS" logs "$POD" --tail=200 (add --previous
only after a restart).
4. Claim -> sandbox -> pod
SB=$(kubectl -n "$NS" get sandboxclaim "$CLAIM" -o jsonpath='{.status.sandbox.name}')
POD=$(kubectl -n "$NS" get sandbox "$SB" -o jsonpath='{.metadata.annotations.agents\.x-k8s\.io/pod-name}')
POD=${POD:-$SB}
kubectl -n "$NS" get sandbox "$SB" -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
kubectl -n "$NS" get sandbox "$SB" -o jsonpath='{.spec.operatingMode}{" "}{.status.serviceFQDN}{" "}{.status.podIPs}{"\n"}'
Rules:
- Resolve user -> sandbox only through
claim.status.sandbox.name; the sandbox keeps its pool-generated name, not the claim name. - A claim tracks binding; for runtime state read
Sandbox.spec.operatingModeand theSuspended/Readyconditions. A suspended sandbox is not serving even when the claim still exists. - A missing
claim.status.sandbox.namemeans the claim did not bind: inspect the Ready conditionreasonand events. The pod is normally named after the sandbox; use the annotation when present.
5. Suspend, resume, wake
Suspend keeps the PVC and Service, deletes the pod:
kubectl -n "$NS" patch sandbox "$SB" --type merge \
-p '{"spec":{"operatingMode":"Suspended"}}'
kubectl -n "$NS" wait --for=delete "pod/$POD" --timeout=120s
kubectl -n "$NS" get pvc "dsh-home-$SB"
kubectl -n "$NS" get svc "$SB"
Resume and verify state:
kubectl -n "$NS" patch sandbox "$SB" --type merge \
-p '{"spec":{"operatingMode":"Running"}}'
kubectl -n "$NS" wait sandbox "$SB" --for=condition=Ready --timeout=180s
kubectl -n "$NS" exec "$SB" -- cat /home/node/.dsh/marker.txt
The gateway wakes a suspended sandbox on the first request. The idle sweeper
only suspends claims it created (aaas.example.com/managed-by=dsh-gateway);
hand-applied claims are never swept.
6. Warm pool
kubectl -n "$NS" get sandboxwarmpool "$POOL" -o yaml
kubectl -n "$NS" get sandboxes -l agents.x-k8s.io/warm-pool-sandbox -o wide
kubectl -n "$NS" get sandboxwarmpool "$POOL" -o jsonpath='{.spec.replicas}{"\n"}'
Adopted sandboxes lose the warm-pool label; count current spares against
spec.replicas. Zero spares or spec.replicas: 0 means cold, slow signup.
Scale only when asked:
kubectl -n "$NS" patch sandboxwarmpool "$POOL" --type merge -p '{"spec":{"replicas":2}}'
7. DSH runtime specifics
- DSH Web listens on
3080in-cluster. Port-forward to a free local port such as18081, never3080if a local DSH already uses it. - The launch token is printed once as
dsh web: http://<ip>:3080/?token=.... Treat it as a credential: do not paste it into reports; hand the user the URL. /returns401without the token. Probes must target/favicon.svg.DSH_TRUSTED_HOSTSmust include any public host that a proxy forwards; DSH fences onHost/Origin.- State lives at
/home/node/.dsh. DSH requires.credentials.yamlto be0600; the template usesfsGroupChangePolicy: OnRootMismatch. If a resumed sandbox crash-loops with a mode-660 error, suspend it, fix the file with a throwaway pod, patch the policy, then resume. - Agent files live at
/workspace, the same PVC mounted withsubPath: workspace; it survives suspend/resume and dies with the PVC. - DSH confines bash with its own file sandbox (
bubblewrap, else Landlock) and fails closed withSANDBOX_UNAVAILABLEwhen neither works. The image shipsbubblewrap, 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 setsDSH_PERMISSION_MODE=danger-full-accessand treats the pod as the boundary. Never "fix" it by grantingCAP_SYS_ADMINorseccompProfile: Unconfinedwithout an explicit decision.
8. Gateway layer
kubectl -n "$NS" get deploy,svc,pods -l app=dsh-gateway -o wide
kubectl -n "$NS" get endpoints dsh-gateway
kubectl -n "$NS" logs deploy/dsh-gateway --tail=100
The gateway is a ClusterIP Service on 8080; it proxies under /u/{user}/ and
derives state (Provisioning, Waking, Ready, Suspending, Suspended).
It performs the DSH launch-token handshake server-side. With SSO enabled
(AUTH_PROXY_HEADER), the Service must be reachable only through the
authenticating proxy; never add a second path or port-forward it on a shared
host. A resource-only install has no gateway: that is a warning, not a failure.
9. Security boundaries
- Provider keys (
dsh-provider-keysplus the optionaldsh-provider-keys-extra) are shared by all sandboxes; the example is not tenant-isolated. - gVisor,
nodeSelector, the sandbox/exposure NetworkPolicies and extra provider keys are opt-in, not active by default. A NetworkPolicy only helps with a CNI that enforces it; see optional hardening. - Secret data, kubeconfigs, and pod logs are sensitive; RBAC may allow Secret access, policy does not. Keep gateway RBAC namespace-scoped; it needs pod logs to find the launch token.
- TLS terminates outside the gateway; set
COOKIE_SECURE=truefor HTTPS.
10. Failure matrix
| Symptom | First evidence | Likely cause / fix |
|---|---|---|
| Claim not Ready, no sandbox | Ready condition reason, events | Empty pool, image pull, quota, unschedulable |
EnvVarsInjectionRejected |
claim Ready reason | Claim carries env/volume templates; template policy rejects it |
| Pod Pending | kubectl describe pod |
Requests/taints/affinity, unbound PVC |
| CrashLoopBackOff | logs, --previous |
Startup failure, bad config, probe 401, fsGroup mode 660 |
| ImagePullBackOff | describe events | Wrong tag, private registry, missing pull secret |
| DSH 401 in browser | gateway logs | Launch-token handshake failed; check gateway RBAC pods/log |
| 502/503 through gateway | sandbox Ready, gateway logs | Wake timeout, DNS stale, token not in logs yet |
| Warm pool empty | sandboxes label count | Scale the pool or wait for replenishment |
| Suspended but expected running | operatingMode |
Resume it; the gateway only wakes on a request |
WS closes 1011 |
gateway logs | DSH rejected the upstream Host/authority; check trusted hosts |
11. Cleanup and reporting
Delete only claims/sandboxes you created, by name:
kubectl -n "$NS" delete sandboxclaim "$CLAIM" --dry-run=server
kubectl -n "$NS" delete sandboxclaim "$CLAIM"
Report context/namespace, evidence, conclusion, change, rollback, and what was deleted. Leave the namespace, pools, templates, and unrelated claims alone.