Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
197 changes: 195 additions & 2 deletions agents/dsh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@ 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:<port> URLs.
DSH_WEB_PORT=3080
DSH_WEB_PROXY_PORT=3081

agent_prepare() {
local -a args
if [ $# -gt 0 ]; then
args=("$@")
else
args=()
fi
AGENT_COMMAND=("dsh")

parse_auth_args "dsh" "${args[@]+"${args[@]}"}"
AUTH_METHOD="$PARSED_AUTH_METHOD"
Expand All @@ -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 <name> 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[@]}"}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the documented dsh opt-out variables

When a user runs DEVA_DSH_WEB=0 deva.sh dsh or sets DEVA_DSH_WORKSPACE_AUTO=0 in the host environment, neither value is added to DOCKER_ARGS or the persistent container's docker exec environment; only the wrapper inside the container reads them, so both resolve to their default of 1. Consequently the documented opt-outs still launch the service and mutate the workspace registry unless users happen to persist the variables through another configuration mechanism.

Useful? React with 👍 / 👎.


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
Comment on lines +195 to +202
setsid nohup dsh web --port "$dsh_web_port" </dev/null >>"$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}" </dev/null >>"$web_log" 2>&1 &

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve isolation when publishing the dsh relay

On the default bridge network, TCP-LISTEN without a bind option listens on every container interface, not only on traffic forwarded by Docker's host-loopback publish. Any peer container that discovers this container's bridge IP can therefore connect directly to port 3081 and send a loopback Host header accepted by dsh's trust fence, exposing the dashboard and the target container's mounted workspace across the repository's container isolation boundary. Add authentication or network filtering that prevents peer containers from reaching this relay.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

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"

Expand Down
121 changes: 121 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
Loading
Loading