From ebe59b7a320f98a864cc0cb7167a99dcb000e854 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Fri, 14 Aug 2026 04:36:34 -0700 Subject: [PATCH 1/3] feat(dsh): ensure the managed web service on every run Bare `dsh` refuses to start (--profile required), so v0.19.0's bare `deva.sh dsh` was dead on arrival. Every run now ensures `dsh web` in the container (the official recommendation), daemonized and idempotent so exec into a running container never double-binds. dsh binds loopback only and hard-rejects 0.0.0.0, which docker -p cannot reach: bridge runs get a socat sidecar bridging a publishable port to the loopback bind, published to the host loopback on the first free port from 3080 (DEVA_DSH_WEB_PORT overrides). Host-net runs skip the sidecar but probe a per-container free port so concurrent containers never adopt each other's server; the ensure check verifies the server is OUR pid namespace's process, not just an open port. Every run also seeds the workspace dir into dsh's registry (storages/workspace.json, domain v2, byte-compatible) so sessions from any profile land pre-grouped in the web UI; the seed is idempotent and backs off from state it does not own. Bare `deva.sh dsh` follows the service log; args after -- run in the foreground with the service ensured behind them. DEVA_DSH_WEB=0 and DEVA_DSH_WORKSPACE_AUTO=0 opt out. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- agents/dsh.sh | 197 +++++++++++++++++++++++++++++++++++++++++++- docs/quick-start.md | 2 +- 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 52e8305..2aadfe3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ deva.sh grok deva.sh kimi deva.sh opencode deva.sh pi -deva.sh dsh +deva.sh dsh # boots the dsh web UI on http://127.0.0.1:3080 deva.sh cursor deva.sh claude --rm # throwaway container diff --git a/agents/dsh.sh b/agents/dsh.sh index 5287fe0..78ca87e 100644 --- a/agents/dsh.sh +++ b/agents/dsh.sh @@ -5,6 +5,16 @@ if [ -f "$(dirname "${BASH_SOURCE[0]}")/shared_auth.sh" ]; then source "$(dirname "${BASH_SOURCE[0]}")/shared_auth.sh" fi +# dsh web serves loopback only: --host takes 127.0.0.1 or 0.0.0.0, and +# 0.0.0.0 is refused at the CLI ("intentionally not supported yet for +# safety"). docker -p cannot reach a container-loopback bind, so bridge +# runs get a socat sidecar inside the container netns bridging +# 0.0.0.0:DSH_WEB_PROXY_PORT -> 127.0.0.1:DSH_WEB_PORT, published to the +# host loopback only. The /api trust fence accepts any loopback Host, so +# no --trusted-host is needed for 127.0.0.1: URLs. +DSH_WEB_PORT=3080 +DSH_WEB_PROXY_PORT=3081 + agent_prepare() { local -a args if [ $# -gt 0 ]; then @@ -12,7 +22,6 @@ agent_prepare() { else args=() fi - AGENT_COMMAND=("dsh") parse_auth_args "dsh" "${args[@]+"${args[@]}"}" AUTH_METHOD="$PARSED_AUTH_METHOD" @@ -38,11 +47,195 @@ agent_prepare() { # future default flip cannot strand mounted state. DOCKER_ARGS+=("-e" "DSH_HOME=/home/deva/.dsh") - AGENT_COMMAND+=("${remaining_args[@]+"${remaining_args[@]}"}") + # Every dsh run ensures the web service (official recommendation; + # bare `dsh` does not even start: `--profile is required`). + # Bare `deva.sh dsh` follows the service log; args after -- run + # that dsh invocation in the foreground with the service ensured + # behind it. DEVA_DSH_WEB=0 skips the service entirely. + setup_dsh_web "${remaining_args[@]+"${remaining_args[@]}"}" setup_dsh_auth "$AUTH_METHOD" } +setup_dsh_web() { + local web_url="" + + if _trace_host_network_args; then + # Host networking: the container loopback IS the host loopback, + # so dsh web is host-reachable with no publish and no sidecar + # (which would otherwise bind 0.0.0.0 in the HOST netns). But + # ALL host-net dsh containers share that one loopback, so each + # container must own a distinct web port: probe a free one here + # and pin it into the container env. Without this, the second + # dsh container sees the first one's server on 3080, never + # starts its own, and the UI serves the WRONG container. + local free_port="" port="${DEVA_DSH_WEB_PORT:-$DSH_WEB_PORT}" tries=0 + while [ "$tries" -lt 12 ]; do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + free_port="$port" + break + fi + port=$((port + 1)) + tries=$((tries + 1)) + done + if [ -z "$free_port" ]; then + echo "warning: no free port from ${DEVA_DSH_WEB_PORT:-$DSH_WEB_PORT} on the host loopback; dsh web may collide with another container" >&2 + free_port="$DSH_WEB_PORT" + fi + DOCKER_ARGS+=("-e" "DEVA_DSH_WEB_PORT_CONTAINER=${free_port}") + web_url="http://127.0.0.1:${free_port}" + else + # Probe a free host port from DEVA_DSH_WEB_PORT (default 3080) + # so concurrent dsh containers land on predictable neighbors -- + # same scheme as the cctrace UI publish. The mapping is fixed at + # container create; DEVA_DSH_WEB_URL travels as container env so + # a later exec into a reused container announces the port that + # was actually published, not this run's re-probe. + local free_port="" port="${DEVA_DSH_WEB_PORT:-$DSH_WEB_PORT}" tries=0 + while [ "$tries" -lt 12 ]; do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + free_port="$port" + break + fi + port=$((port + 1)) + tries=$((tries + 1)) + done + if [ -n "$free_port" ]; then + DOCKER_ARGS+=("-p" "127.0.0.1:${free_port}:${DSH_WEB_PROXY_PORT}") + DOCKER_ARGS+=("-e" "DEVA_DSH_PROXY_PORT=${DSH_WEB_PROXY_PORT}") + web_url="http://127.0.0.1:${free_port}" + else + echo "warning: no free host port from ${DEVA_DSH_WEB_PORT:-$DSH_WEB_PORT}; dsh web UI will not be reachable from the host" >&2 + fi + fi + if [ -n "$web_url" ]; then + DOCKER_ARGS+=("-e" "DEVA_DSH_WEB_URL=${web_url}") + fi + + # In-container boot wrapper, three duties on EVERY dsh run: + # 1. register the workspace in dsh's registry (all modes, so tui/ + # headless sessions land pre-grouped in the web UI); + # 2. ensure the web service: daemonize `dsh web` + the socat + # loopback bridge if not already listening -- idempotent, so + # exec into a running container never EADDRINUSEs, and the + # service survives the exec session ending; + # 3. run the user's dsh args in the foreground, or follow the + # service log when there are none. + # The workspace seed follows @deepseek-ai/dsh-workspace's durable + # schema (storages/workspace.json, domain version 2) byte-for-byte: + # realpath canon, uuid id, basename title, prepend to order. It + # backs off from anything it does not own outright -- foreign + # schema version, pending mutation marker, an uninitialized + # registry with session history (dsh's one-time header bootstrap + # must group that history first; the seed retries next launch). + # DEVA_DSH_WORKSPACE_AUTO=0 disables. Eligible: the deva workspace + # mount itself (DEVA_WORKSPACE/WORKDIR create-time env -- the dir + # the user pointed deva at IS the workspace, git repo or not), or + # any dir with a .git entry (dir or worktree file). Skips and + # backoffs say why -- a silent no-op here is undebuggable. + local wrapper="" + read -r -d '' wrapper <<'WRAPPER' || true +if [ "${DEVA_DSH_WORKSPACE_AUTO:-1}" = "1" ]; then +if [ -e .git ] || [ "$PWD" = "${DEVA_WORKSPACE:-}" ] || [ "$PWD" = "${WORKDIR:-}" ]; then + node --input-type=commonjs <<'SEED' || echo "deva: dsh workspace auto-add failed (non-fatal)" >&2 +const fs = require('fs'), path = require('path'), os = require('os'), crypto = require('crypto'); +const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh'); +const file = path.join(home, 'storages', 'workspace.json'); +const cwd = fs.realpathSync(process.cwd()); +const skip = why => { console.error('deva: dsh workspace auto-add skipped: ' + why); process.exit(0); }; +let state; +if (fs.existsSync(file)) { + state = JSON.parse(fs.readFileSync(file, 'utf8')); + // Foreign shape or an in-flight registry mutation: hands off, dsh owns it. + if (!state || !state.unit || state.unit.name !== 'workspace' || state.unit.version !== 2) + skip('unknown registry schema, leaving it to dsh'); + if (!state.global || state.global.initialized !== true) + skip('registry not initialized (dsh bootstrap runs first; retries next launch)'); + if (state.global.pendingMutation) + skip('registry mutation in flight (dsh recovers it; retries next launch)'); +} else { + // First boot: pre-initialize only an EMPTY registry. With session + // history on disk, dsh's own header bootstrap must group it first + // (the initialized marker is written last on purpose) -- the seed + // gets another chance on the next launch. + try { + if (fs.readdirSync(path.join(home, 'sessions')).length > 0) + skip('no registry but session history exists (dsh bootstrap runs first; retries next launch)'); + } catch {} + state = { unit: { name: 'workspace', version: 2 }, + global: { initialized: true, workspaceIds: [], archivedSessionIds: [] }, + tables: { workspaces: {} } }; +} +if (!state.tables) state.tables = {}; +if (!state.tables.workspaces) state.tables.workspaces = {}; +const table = state.tables.workspaces; +if (Object.values(table).some(r => r && r.path === cwd)) process.exit(0); +const id = crypto.randomUUID(); +const now = new Date().toISOString(); +table[id] = { path: cwd, title: path.basename(cwd), sessionIds: [], createdAt: now, updatedAt: now }; +state.global.workspaceIds = [id].concat(state.global.workspaceIds || []); +fs.mkdirSync(path.dirname(file), { recursive: true }); +const tmp = file + '.deva-seed'; +fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); +fs.renameSync(tmp, file); +console.error('deva: registered workspace ' + cwd + ' in dsh'); +SEED +else + echo "deva: dsh workspace auto-add skipped: $PWD is not the deva workspace and has no .git" >&2 +fi +fi +_dsh_port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; } +# Our own server, in THIS container's pid namespace. An open port is +# NOT proof of ours: under host networking another dsh container's +# server answers the same loopback. (The pgrep pattern cannot match +# this wrapper itself -- here the port is an unexpanded variable.) +_dsh_web_ours() { pgrep -f "dsh web --port ${1}" >/dev/null 2>&1; } + +if [ "${DEVA_DSH_WEB:-1}" = "1" ]; then + dsh_web_port="${DEVA_DSH_WEB_PORT_CONTAINER:-3080}" + web_log="${DSH_HOME:-$HOME/.dsh}/web.log" + mkdir -p "$(dirname "$web_log")" + if _dsh_web_ours "$dsh_web_port"; then + : # already serving + elif _dsh_port_open "$dsh_web_port"; then + echo "deva: 127.0.0.1:${dsh_web_port} is bound outside this container (another host-net dsh container?) -- not starting dsh web; recreate this container to allocate a fresh port" >&2 + else + setsid nohup dsh web --port "$dsh_web_port" >"$web_log" 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + _dsh_port_open "$dsh_web_port" && break + sleep 1 + done + if ! _dsh_port_open "$dsh_web_port"; then + echo "deva: dsh web failed to start; last log lines:" >&2 + tail -n 20 "$web_log" >&2 || true + fi + fi + if [ -n "${DEVA_DSH_PROXY_PORT:-}" ] && ! _dsh_port_open "$DEVA_DSH_PROXY_PORT"; then + setsid nohup socat "TCP-LISTEN:${DEVA_DSH_PROXY_PORT},fork,reuseaddr" "TCP:127.0.0.1:${dsh_web_port}" >"$web_log" 2>&1 & + fi + if _dsh_web_ours "$dsh_web_port"; then + if [ -n "${DEVA_DSH_WEB_URL:-}" ]; then + echo "deva: dsh web UI: ${DEVA_DSH_WEB_URL}" >&2 + else + echo "deva: dsh web running on container loopback only (container predates the web publish; recreate it for host access)" >&2 + fi + fi +fi +if [ $# -gt 0 ]; then + exec dsh "$@" +fi +if [ "${DEVA_DSH_WEB:-1}" != "1" ]; then + echo "deva: DEVA_DSH_WEB=0 and no dsh args given; nothing to run" >&2 + exit 2 +fi +exec tail -n 20 -f "${DSH_HOME:-$HOME/.dsh}/web.log" +WRAPPER + AGENT_COMMAND=("bash" "-c" "$wrapper" "dsh-web") + if [ $# -gt 0 ]; then + AGENT_COMMAND+=("$@") + fi +} + setup_dsh_auth() { local method="$1" diff --git a/docs/quick-start.md b/docs/quick-start.md index fbe2281..f7b101f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -83,7 +83,7 @@ deva.sh grok deva.sh kimi deva.sh opencode deva.sh pi -deva.sh dsh +deva.sh dsh # web UI on http://127.0.0.1:3080; repo auto-added as workspace deva.sh cursor ``` From 7ee1af444010fb565e8b3be73be5e3ebd747dc5b Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Fri, 14 Aug 2026 04:36:41 -0700 Subject: [PATCH 2/3] test(dsh-auth): pin managed web boot wiring Publish + sidecar env on bridge, per-container port under host-net (no publish), wrapper passthrough of user args, and web ensured in every mode. Existing asserts updated for the dsh-web wrapper argv0. Co-Authored-By: Claude Fable 5 --- scripts/test-dsh-auth.sh | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/test-dsh-auth.sh b/scripts/test-dsh-auth.sh index b55ff59..201612b 100755 --- a/scripts/test-dsh-auth.sh +++ b/scripts/test-dsh-auth.sh @@ -48,6 +48,27 @@ want "auth method is credentials" "DEVA_AUTH_METHOD=credentials" "$cred_out want "permission bypass wired" "DSH_PERMISSION_MODE=danger-full-access" "$cred_out" want "home pinned" "DSH_HOME=/home/deva/.dsh" "$cred_out" +echo "=== dsh default: managed web boot (publish + sidecar + workspace seed) ===" +web_out="$(DEVA_DSH_WEB_PORT=39080 run_dry dsh --debug --dry-run || true)" +want "web port published to host loopback" "-p 127.0.0.1:39080:3081" "$web_out" +want "sidecar proxy port wired" "DEVA_DSH_PROXY_PORT=3081" "$web_out" +want "web service daemonized in wrapper" "setsid nohup dsh web --port" "$web_out" +want "workspace auto-add in boot wrapper" "DEVA_DSH_WORKSPACE_AUTO" "$web_out" + +echo "=== dsh --host-net: web boots on host loopback, no publish, no sidecar ===" +hostnet_out="$(run_dry dsh --host-net --debug --dry-run || true)" +# the wrapper body mentions DEVA_DSH_PROXY_PORT as its runtime guard; +# only the -e wiring form proves a publish +want_absent "no port publish under host net" "DEVA_DSH_PROXY_PORT=3081" "$hostnet_out" +want "web still ensured under host net" "setsid nohup dsh web --port" "$hostnet_out" +want "per-container port under host net" "DEVA_DSH_WEB_PORT_CONTAINER=" "$hostnet_out" + +echo "=== dsh user args: foreground passthrough, web service still ensured ===" +pass_out="$(DEVA_DSH_WEB_PORT=39080 run_dry dsh --debug --dry-run -- --profile tui || true)" +want "user profile appended after wrapper" "dsh-web --profile tui" "$pass_out" +want "web published on passthrough too" "-p 127.0.0.1:39080:3081" "$pass_out" +want "web ensured on passthrough too" "setsid nohup dsh web --port" "$pass_out" + echo "=== dsh credentials: hybrid config-root mounts ~/.dsh ===" # Seed the config-root layout an autolinked run leaves behind and assert # the centralized walk (mount_agent_canonical) emits the mount. @@ -59,7 +80,7 @@ echo "=== dsh api-key: DEEPSEEK_API_KEY as env, no mount ===" apikey_out="$(DEEPSEEK_API_KEY=sk-ds-test-1234 run_dry dsh --auth-with api-key --dry-run -- --profile headless hi || true)" want "key wired + redacted" "DEEPSEEK_API_KEY=" "$apikey_out" want "key last-4 tags container" "--api-key-1234--" "$apikey_out" -want "passes agent args after --" "dsh --profile headless hi" "$apikey_out" +want "passes agent args after --" "dsh-web --profile headless hi" "$apikey_out" want_absent "no ~/.dsh mount in api-key mode" ":/home/deva/.dsh\"" "$apikey_out" echo "=== dsh api-key: no mount on the hybrid config-root path either ===" @@ -80,7 +101,7 @@ want "trace rejected" "--trace is not supported for dsh" "$trace_out" echo "=== dsh --trace after -- is passthrough ===" trace_pass_out="$(run_dry dsh --dry-run -- --trace || true)" want_absent "--trace after -- not absorbed" "--trace is not supported" "$trace_pass_out" -want "--trace passed to agent" "dsh --trace" "$trace_pass_out" +want "--trace passed to agent" "dsh-web --trace" "$trace_pass_out" echo "=== dsh api-key: missing key errors ===" missing_out="$(DEEPSEEK_API_KEY= run_dry dsh --auth-with api-key --dry-run || true)" From b5a53d7c321c5942db988bb07432d63226adca7d Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Fri, 14 Aug 2026 04:36:41 -0700 Subject: [PATCH 3/3] docs(auth): dsh web service semantics + testing-auth guide Document the managed dsh web boot (ports, host-net, shared-storage caveat, workspace seed) and add a three-layer Testing Auth guide: hermetic wiring tests, dry-run against real auth, live smoke with the per-agent first-run table. Default homes list catches up with pi, dsh, and cursor. Co-Authored-By: Claude Fable 5 --- docs/authentication.md | 121 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/docs/authentication.md b/docs/authentication.md index f117eba..9c2f28f 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -503,6 +503,58 @@ surface — pass it after `--`. ## dsh +Every `deva.sh dsh` run ensures the web service in its container +(`dsh web`, the official recommendation; bare `dsh` refuses to start +without a profile): if nothing listens on the container's 3080, the +web profile is daemonized (log: `$DSH_HOME/web.log`) and survives the +launch session ending — re-entering a running container never +double-binds. Bare `deva.sh dsh` then follows the service log; args +after `--` run that dsh invocation in the foreground with the service +ensured behind it: + +```bash +deva.sh dsh # ensure web UI, follow its log +deva.sh dsh -- --profile headless "run the tests" +deva.sh dsh -- --profile tui +``` + +dsh serves loopback only and hard-rejects `0.0.0.0`, which docker `-p` +cannot reach — so deva daemonizes a socat sidecar in the container +bridging a publishable port to the loopback bind, published to the +host loopback. Each container gets the first free host port from 3080 +(`DEVA_DSH_WEB_PORT` overrides the probe start), so concurrent dsh +containers land on 3080, 3081, ... The mapping is fixed at container +create and travels as container env, so a reused container announces +the port it actually published, not a fresh probe (containers created +before this feature have no publish — recreate them for host access). +Under `--host-net` there is no sidecar and no publish — the container +loopback is the host loopback — but every host-net dsh container +shares that one loopback, so deva probes a free port per container +(first free from 3080) and pins it into the container env. The ensure +check verifies the server is OUR container's process, not just an open +port: another container's server answering our port is reported, never +adopted (its UI serves that container's mounts, not ours). The `/api` +trust fence accepts loopback Hosts, so no `--trusted-host` wiring is +needed. `DEVA_DSH_WEB=0` skips the service entirely. + +Caveat: dsh containers sharing one auth home share one +`storages/` (registry, session index). dsh tolerates but does not +coordinate concurrent writers — a live server in another container may +overwrite registry entries seeded after it booted; relaunching re-adds +them. Use `--config-home` for fully isolated dsh state. + +Every run (web, tui, headless) also registers the workspace dir in +dsh's workspace registry (`$DSH_HOME/storages/workspace.json`) — the +dir you pointed deva at is the workspace by definition (git repo or +not); other cwds qualify only with a `.git` entry. Sessions from any +profile land pre-grouped in the web UI, and skips log their reason to +the launch output. The seed follows the durable +domain schema exactly, is idempotent per canonical path, and backs off +from state it does not own (foreign schema version, pending mutation, +an uninitialized registry with session history — dsh's own bootstrap +runs first, the seed retries next launch). `DEVA_DSH_WORKSPACE_AUTO=0` +disables it. + ### Default: `--auth-with credentials` Mounts `~/.dsh` (`$DSH_HOME`; deva pins it to `/home/deva/.dsh` because @@ -595,6 +647,9 @@ Default homes live under: ~/.config/deva/grok ~/.config/deva/kimi ~/.config/deva/opencode +~/.config/deva/pi +~/.config/deva/dsh +~/.config/deva/cursor ``` Use `--config-home` when you want a separate identity: @@ -611,6 +666,72 @@ Good reasons to split auth homes: - different org endpoints - reproducing auth bugs without contaminating your default state +## Testing Auth + +Three layers, cheapest first. Run them in order — most auth bugs die +before a container ever starts. + +### 1. Wiring tests (no Docker, no credentials) + +Hermetic per-agent tests run `deva.sh` with a scratch `HOME` and +`DEVA_NO_DOCKER=1`, then assert the planned mounts and env for each +`--auth-with` mode: + +```bash +bash scripts/test-kimi-auth.sh +bash scripts/test-opencode-auth.sh +bash scripts/test-pi-auth.sh +bash scripts/test-dsh-auth.sh +bash scripts/test-cursor-auth.sh +``` + +They prove deva wires the right thing (mount present in credentials +mode, key redacted and no mount in api-key mode, blank overlay when +non-default auth is active). They never touch your real +`~/.config/deva` or credentials. + +### 2. Dry-run against your real auth (no container) + +```bash +deva.sh dsh --debug --dry-run +deva.sh dsh --auth-with api-key --debug --dry-run +``` + +Same checklist as Debugging Auth below: auth label, env vars, mounts, +overlay. Still proves nothing about whether the token works. + +### 3. Live smoke (spends tokens) + +Launch the agent and run one trivial prompt. What "authed" requires +per agent, default mode: + +| Agent | Auth lives in | First-run step | +|-------|---------------|----------------| +| Claude | `~/.claude` + `~/.claude.json` | `/login` in TUI | +| Codex | `~/.codex/auth.json` | `codex login` device flow | +| Gemini | `~/.gemini` | browser OAuth | +| Grok | `~/.grok/auth.json` | in-app login | +| Kimi | `~/.kimi-code` | device-code flow | +| opencode | `~/.local/share/opencode/auth.json` | device-code flow | +| pi | `~/.pi/agent/auth.json` | `/login` in TUI | +| dsh | `~/.dsh/.credentials.yaml` | no login flow — dsh prompts for the key on first run, or write the file yourself | +| cursor | config home `.config/cursor/auth.json` | `cursor-agent login` inside the container (deva prints the URL) | + +Login-in-container flows persist because the auth home is mounted — +the second run is authed without repeating the step. + +To smoke-test without touching your default identity, point the run +at a throwaway config home: + +```bash +deva.sh dsh -c "$(mktemp -d)" +``` + +api-key modes need no first-run step at all — export the key +(`DEEPSEEK_API_KEY`, `CURSOR_API_KEY`, provider keys for pi, ...) and +run with `--auth-with api-key`. See each agent's section above for +which env var decides billing. + ## Debugging Auth Useful commands: