Status: v1 design, implemented in this repository.
Argo Workflows has no way to open a shell into a step pod from its UI, and no
UI-extension mechanism through which one could be added
(argoproj/argo-workflows#6945,
open since 2021; #7949:
"use kubectl exec"). Generic Kubernetes web terminals exist, but they either
ship without authentication (k8shell, kube-webshell) or are full platforms
(Wayne, Rancher). None of them understands Argo's debug pause mechanism
(#6841), which was
implemented backend-only: two environment variables and a marker file.
What operators actually want:
- open a shell into a running step pod straight from the Argo UI — tail a log, look at files on a volume, see what processes are doing;
- get in even when the step image has no shell;
- when a step is too short-lived to catch, re-run it with a breakpoint and release it once done inspecting;
- every session attributable to a person, authorized by the cluster's own RBAC.
Goals
- G1 Deep-linkable web terminal into any pod/container:
/shell/{namespace}/{pod}?container=main. - G2 Per-user identity. OIDC login, then Kubernetes impersonation. The
server holds no
pods/execrights of its own; the cluster's RBAC decides. - G3 Works on shell-less (distroless) images through ephemeral debug containers that share the target's PID namespace and volume mounts.
- G4 Argo Workflows awareness, optional: workflow/node view, "re-run with breakpoint", release. Pausing is opt-in per re-run; a live pod is always shell-able without pausing.
- G5 Standalone: single binary with embedded UI, Helm chart. Integrates with Argo Workflows and Argo CD through their native link configuration.
- G6 Auditable: structured log line and Kubernetes Event per session; impersonated identity shows up in API-server audit logs.
Non-goals for v1
- Multi-cluster.
- Session recording / replay.
- File upload/download (use the debug container).
- Being a general Kubernetes dashboard.
- OIDC refresh tokens: the session TTL bounds session length instead.
- Server-side session store: sessions are encrypted cookies, so replicas only need to share the session key.
browser ──── HTTPS ────► stepshell ──── impersonated REST / WebSocket ────► kube-apiserver
│ xterm.js │ OIDC client (code + PKCE) │ RBAC decides:
│ SPA (embedded) │ session = AES-GCM cookie │ pods/exec
└─ WS /ws/exec ───────────┤ exec bridge (client-go remotecommand) │ pods/ephemeralcontainers
└─ audit: slog JSON + Kubernetes Events │ workflows.argoproj.io
IdP (Dex / Okta / Google / Entra …) ◄── authorization code ──┘
One Go binary, no database. Request lifecycle: session cookie → identity
(user, groups) → rest.ImpersonationConfig → per-request client → API
call. When RBAC denies, the user sees the API server's own Forbidden message.
oidc— production. Authorization-code flow with PKCE (S256) and nonce.none— development only. A fixed identity from--dev.user/--dev.groups. Logs a warning at startup and refuses to start unless--base-urlis a localhost URL or--auth.allow-insecure-noneis set.
- Username from
--oidc.username-claim(defaultemail), groups from--oidc.groups-claim(defaultgroups). - Optional
--impersonate.user-prefix/--impersonate.group-prefixso the impersonated subjects match whatever convention the cluster's RBAC uses (mirrors kube-apiserver's--oidc-username-prefix/--oidc-groups-prefix). - Session cookie:
{user, groups, email, iat, exp}encrypted and authenticated with AES-256-GCM.HttpOnly; Secure; SameSite=Lax. TTL default8h.Laxso that top-level navigations from the Argo UI carry the cookie (deep-links work) while cross-site POSTs do not.
Every Kubernetes call carries Impersonate-User and Impersonate-Group
headers. The server's ServiceAccount needs exactly:
impersonateonusersandgroups(cluster-scoped by nature);createoneventsfor the audit trail (optional).
It holds no pods/exec, pods/ephemeralcontainers, or Argo permissions. Trust
boundary: whoever controls the stepshell process can impersonate any user the
rule allows. Mitigations: pin allowed groups with resourceNames on the
impersonate groups rule when the set is known, NetworkPolicy, distroless
read-only image. This is the same model used by kube-oidc-proxy, Teleport and
Dex-fronted gateways.
Namespaced Role, bound to the user or their group:
| resource | verbs | needed for |
|---|---|---|
pods |
get, list, watch | pod picker, pod page |
pods/exec |
create | terminal, pause release |
pods/ephemeralcontainers |
update | debug container |
pods/log |
get | (optional, future) |
workflows.argoproj.io |
get, list, create | Argo view, breakpoint re-run |
workflowtemplates.argoproj.io |
get | inlining workflowTemplateRef |
clusterworkflowtemplates.argoproj.io (ClusterRole) |
get | same, cluster-scoped |
The chart renders these from userAccess in values.
- Mutating endpoints require the header
X-Stepshell: 1. Browsers never attach custom headers cross-site without a CORS preflight, which is never granted. - WebSocket handshakes must present an
Originequal to--base-url. rd(return-to after login) must be a local absolute path (/…, not//…).
GET /ws/exec?ns=&pod=&container=&cmd= upgrades to a WebSocket and runs a
TTY exec through client-go/tools/remotecommand (WebSocket transport first,
SPDY fallback).
- Default command:
/bin/sh -c 'export TERM=xterm-256color; command -v bash >/dev/null 2>&1 && exec bash || exec sh'. cmdrunssh -c <cmd>instead — deep-links like?cmd=tail -f /var/log/app.log.- Resize is forwarded; the server pings every 25s so load-balancer idle timeouts (ALB default 60s) don't kill quiet sessions.
- When exec fails with
executable file not foundthe UI offers "Attach debug container".
POST /api/v1/namespaces/{ns}/pods/{pod}/debug {target, image?} appends an
EphemeralContainer:
name: stepshell-<rand>,image(defaultnicolaka/netshoot),command: ["tail","-f","/dev/null"],targetContainerName: <target>;volumeMounts: the target container's mounts (excluding projected ServiceAccount tokens) plusvar-run-argowhen the pod has it, so Argo's marker files are reachable.
Waits until the container is running (60s), returns its name. An existing
running stepshell-* container targeting the same container is reused.
Ephemeral containers cannot be removed; they end with the pod.
Inside the debug container: ps shows the target's processes (shared PID
namespace), /proc/1/root is the target's root filesystem, and the target's
volumes are mounted at the same paths.
GET /api/v1/namespaces/{ns}/workflows— recent workflows.GET /api/v1/namespaces/{ns}/workflows/{name}— nodes flattened: id, name, displayName, type, templateName, phase, timestamps,podName,podExists,paused(derived: node Running and pod annotation/labels), containers. Pod names come from live pods (workflows.argoproj.io/workflowlabel,workflows.argoproj.io/node-idannotation); for deleted pods the v2 name is computed (<wf>-<template>-<fnv32a(nodeName)>) andpodExists=false.POST /api/v1/namespaces/{ns}/workflows/{name}/debug{stage: "before"|"after"|"both", templates: [..]}creates<name>-dbg-<rand>:- metadata stripped; labels
stepshell.io/debug-of=<name>; annotationstepshell.io/requested-by=<user>; spec.podGC.strategy=OnWorkflowCompletionso paused/finished pods survive while the debug run lives;spec.ttlStrategy.secondsAfterCompletionset from--argo.debug-ttl;- a top-level
workflowTemplateRefis resolved and inlined (templates, entrypoint, arguments merged by name, volumes, …), because the env has to land on the template's container for Argo to also liftactiveDeadlineSeconds(workflowpod.gochecks template env, andpodSpecPatchis applied after that check); ARGO_DEBUG_PAUSE_BEFORE/_AFTER="true"injected intocontainer,scriptandcontainerSetentries of the selected templates (empty selection = all);- if a selected template cannot be found inline (step-level
templateRef), falls back to a workflow-levelpodSpecPatchon containermainand clearsspec.activeDeadlineSeconds; the response carries a warning.
- metadata stripped; labels
POST /api/v1/namespaces/{ns}/pods/{pod}/release{container, stage}creates/var/run/argo/ctr/<container>/<stage>: firstsh -c touchinside the container; when the image has no shell (argoexecitself is distroless, so thewaitsidecar is no help), attaches or reuses a debug container withvar-run-argomounted and touches from there. Response says which path was taken.
/— namespace picker; tabs Workflows (when Argo is enabled) and Pods./wf/{ns}/{name}— node table with phase, pod, Shell (live pods), Release (paused nodes); "Re-run with breakpoint" panel (templates, before/after). Auto-refresh while the workflow is running./shell/{ns}/{pod}?container=&cmd=— terminal, container selector, pod facts, Attach debug container, Release pause (Argo pods), link back to the workflow.
All endpoints are JSON and require a session, except /auth/*, /healthz,
/readyz and static assets.
| method | path | notes |
|---|---|---|
| GET | /api/v1/me |
identity, capabilities (argo on/off) |
| GET | /api/v1/namespaces |
impersonated list; falls back to --namespaces |
| GET | /api/v1/namespaces/{ns}/pods |
|
| GET | /api/v1/namespaces/{ns}/pods/{pod} |
containers, ephemeral containers, argo labels |
| POST | /api/v1/namespaces/{ns}/pods/{pod}/debug |
attach debug container |
| POST | /api/v1/namespaces/{ns}/pods/{pod}/release |
release debug pause |
| GET | /api/v1/namespaces/{ns}/workflows |
argo |
| GET | /api/v1/namespaces/{ns}/workflows/{name} |
argo |
| POST | /api/v1/namespaces/{ns}/workflows/{name}/debug |
argo |
| GET | /ws/exec |
WebSocket |
| GET | /auth/login?rd= |
|
| GET | /auth/callback |
|
| POST | /auth/logout |
|
| GET | /healthz, /readyz |
Errors: {"error": "<message>", "code": <http status>}. Kubernetes
StatusErrors pass through their status code and message so a Forbidden
reads exactly like kubectl.
Client → server
- binary frame: stdin bytes
- text frame:
{"type":"resize","cols":N,"rows":N}
Server → client
- binary frame: terminal output
- text frame:
{"type":"ready","session":"<id>"},{"type":"exit","code":N,"error":"<message>"}
Close codes: 1000 normal, 4401 unauthenticated, 4403 forbidden,
4404 not found, 4500 exec error.
Flags, each also readable from STEPSHELL_<UPPER_SNAKE>; env STEPSHELL_OIDC_CLIENT_SECRET
is the expected way to pass the secret.
| flag | default | |
|---|---|---|
--listen |
:8080 |
|
--base-url |
required | public URL, used for OIDC redirect and Origin check |
--auth.mode |
oidc |
oidc or none |
--oidc.issuer |
||
--oidc.client-id |
||
--oidc.client-secret |
env only, recommended | |
--oidc.scopes |
openid,profile,email,groups |
|
--oidc.username-claim |
email |
|
--oidc.groups-claim |
groups |
|
--impersonate.user-prefix |
||
--impersonate.group-prefix |
||
--session.key |
random | base64, 32 bytes; set it for restarts/replicas |
--session.ttl |
8h |
|
--namespaces |
fallback list when the user can't list namespaces | |
--debug.image |
nicolaka/netshoot:latest |
|
--exec.default-command |
see 5.1 | |
--argo.enabled |
true |
|
--argo.ui-url |
for "open in Argo" links | |
--argo.debug-ttl |
3600 |
seconds, ttlStrategy on debug runs |
--kubeconfig |
empty = in-cluster | |
--dev.user, --dev.groups |
auth mode none |
|
--audit.events |
true |
Kubernetes Event per exec |
--log.level |
info |
Helm chart charts/stepshell:
- Deployment: distroless nonroot image,
readOnlyRootFilesystem, drop all caps. - Service, Ingress (annotations pass-through; note ALB
idle_timeout). - ServiceAccount, ClusterRole (
impersonate users/groups,create events), ClusterRoleBinding. - Secret for OIDC client secret and session key;
existingSecretsupported. - Optional
userAccess: per-namespace Role + RoleBinding for the subjects listed (section 4.4), plus the ClusterRole forclusterworkflowtemplates. - Probes on
/healthzand/readyz.
Argo Workflows (controller.links in the Helm chart, or links: in the
workflow-controller ConfigMap):
- name: Shell
scope: pod
target: _blank
url: https://stepshell.example.com/shell/${metadata.namespace}/${metadata.name}?container=main
- name: Debug workflow
scope: workflow
target: _blank
url: https://stepshell.example.com/wf/${metadata.namespace}/${metadata.name}Argo CD (argocd-cm, deep links):
resource.links: |
- url: https://stepshell.example.com/shell/{{.resource.metadata.namespace}}/{{.resource.metadata.name}}
title: Shell
icon.class: fa-terminal
if: resource.kind == "Pod"slog JSON events: auth.login, auth.logout, exec.start, exec.end
(duration, bytes in/out, exit code), debug.attach, argo.debug.submit,
argo.release. Common fields: user, groups, session, namespace,
pod, container, command, remote_addr.
With --audit.events, a Kubernetes Event (reason=StepshellExec) is recorded
on the pod, so kubectl describe pod shows who opened a shell. Because every
call is impersonated, the API server's audit log attributes pods/exec to the
real user.
- Server compromise ⇒ impersonation of any allowed user. Keep the process small: distroless, read-only FS, NetworkPolicy, pinned groups.
- Cookie theft ⇒ bounded by TTL;
Secure,HttpOnly; logout clears. - Inside the pod the user has whatever the pod's ServiceAccount token
allows, exactly as with
kubectl exec. SetautomountServiceAccountToken: falseon workflow pods that don't need the API. - Debug containers mount the target's volumes: same exposure as exec into the target, by design.
- Open redirect prevented by validating
rd.
- Session recording (asciicast) to object storage.
- File browser / download from the debug container.
- Multi-cluster via kubeconfig contexts.
- Refresh tokens.
- Argo Workflows UI plugin, if #6945 ever lands.