Files
devdesk/tests/attach.bats
T

930 lines
42 KiB
Bash
Raw Normal View History

#!/usr/bin/env bats
# Exercises bin/devdesk and the libexec helpers against a throwaway tmux server.
REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
setup() {
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
export DEVDESK_ENV=/dev/null
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
export DEVDESK_STATE_DIR="$TMP/state"
export DEVDESK_SOCKET="$TMP/tmux.sock"
export DEVDESK_LIBEXEC="$REPO/libexec"
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
2026-10-11 01:01:51 -04:00
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
export PATH="$REPO/bin:$PATH"
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
cat >"$CLAUDE_BIN" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
2026-10-11 01:01:51 -04:00
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
exec sleep 300
EOF
chmod +x "$CLAUDE_BIN"
}
teardown() {
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
rm -rf "$TMP"
}
start_server() {
tmux -u -D -S "$DEVDESK_SOCKET" -f "$REPO/share/tmux.conf" &
"$REPO/libexec/devdesk-tmux-ready"
}
# Wait for the fake claude in SESSION to record its arguments.
claude_args() {
for _ in $(seq 1 50); do
[ -s "$FAKE_LOG/$1" ] && { tail -n1 "$FAKE_LOG/$1"; return 0; }
sleep 0.1
done
return 1
}
@test "rejects invalid session names" {
start_server
for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do
run devdesk attach "$bad"
[ "$status" -ne 0 ]
[[ $output == *"invalid session name"* ]]
done
run devdesk attach -x
[ "$status" -ne 0 ]
[[ $output == *"unknown option"* ]]
run devdesk attach one two
[ "$status" -ne 0 ]
}
@test "fails clearly when the tmux server is down" {
run devdesk new alpha
[ "$status" -ne 0 ]
[[ $output == *"tmux server is not running"* ]]
}
@test "new session gets claude and shell windows in the project dir" {
start_server
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-windows -t =alpha -F '#{window_name}:#{pane_current_path}'
[ "${lines[0]}" = "claude:$DEVDESK_PROJECTS_ROOT/alpha" ]
[ "${lines[1]}" = "shell:$DEVDESK_PROJECTS_ROOT/alpha" ]
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
run jq -r '.sessions.alpha.dir' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
}
@test "creating an existing session is a no-op" {
start_server
devdesk new alpha
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-sessions -F '#{session_name}'
[ "$output" = "alpha" ]
}
@test "default session maps to the projects root" {
start_server
devdesk new main
run tmux -S "$DEVDESK_SOCKET" display -p -t =main:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "directories with spaces are reachable by slug" {
start_server
run devdesk new test-code
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =test-code:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ]
}
@test "sessions without a project start in the default folder" {
start_server
run devdesk new pbs-upgrade
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =pbs-upgrade:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "symlinks may not escape the projects root" {
start_server
run devdesk new escape
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =escape:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
run devdesk new other escape
[ "$status" -ne 0 ]
[[ $output == *"no project 'escape'"* ]]
}
@test "new NAME PROJECT starts the session in that project" {
start_server
run devdesk new ops-alpha alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =ops-alpha:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
run devdesk new ops-alpha alpha
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
}
@test "rename moves a running session, its registry entry and its context" {
start_server
devdesk new alpha
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
mkdir -p "$DEVDESK_STATE_DIR/context" && echo '{"used_pct":12}' >"$DEVDESK_STATE_DIR/context/alpha.json"
run devdesk rename alpha ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" show-environment -t =ops DEVDESK_SESSION
[ "$output" = "DEVDESK_SESSION=ops" ]
[ "$(jq -r '.sessions.ops.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = abc-123 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ -f "$DEVDESK_STATE_DIR/context/ops.json" ]
[ ! -e "$DEVDESK_STATE_DIR/context/alpha.json" ]
}
@test "rename refuses clashes and unknown sessions" {
start_server
devdesk new alpha
devdesk new main
run devdesk rename alpha main
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
run devdesk rename nosuch other
[ "$status" -ne 0 ]
[[ $output == *"no session named"* ]]
run devdesk rename alpha "bad name"
[ "$status" -ne 0 ]
}
@test "rename works for dormant sessions" {
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
jq '.sessions.gone.dir = "/tmp"' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk rename gone archived
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions.archived.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = old-1 ]
}
@test "hooks follow the tmux session name after a rename" {
start_server
devdesk new alpha
devdesk rename alpha renamed
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =renamed:shell '#{pane_id}')
echo '{"session_id":"after-rename","source":"clear"}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
[ "$(jq -r '.sessions.renamed.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = after-rename ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "statusline snapshots context for the session and --list reports it" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
status_json='{"session_id":"s1","model":{"id":"m","display_name":"Opus"},"effort":{"level":"high"},
"context_window":{"context_window_size":1000000,"used_percentage":34.6,
"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":2000,"cache_read_input_tokens":343000}},
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
[ "$status" -eq 0 ]
[[ $output == *"alpha"* && $output == *"ctx 34%"* ]]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "hooks ignore Claude running in some other tmux server" {
start_server
other="$TMP/other.sock"
tmux -S "$other" new-session -d -s stranger 'sleep 60'
pane=$(tmux -S "$other" display -p -t =stranger '#{pane_id}')
echo '{"session_id":"nope","source":"startup"}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-session-hook"
echo '{"model":{"display_name":"Opus"},"context_window":{}}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null
tmux -S "$other" kill-server
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ] || [ "$(jq -r '.sessions | has("stranger")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ ! -e "$DEVDESK_STATE_DIR/context/stranger.json" ]
}
@test "repo reports git state and recognises Gitea remotes" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9 # nothing listens: CI lookups fail fast
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
git -C "$repo" remote add origin gitea@10.10.10.63:LotusGuild/alpha.git
touch "$repo/new-file"
run devdesk attach --repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.git' <<<"$output")" = true ]
[ "$(jq -r '.branch' <<<"$output")" = main ]
[ "$(jq -r '.dirty' <<<"$output")" = 1 ]
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha ]
[ "$(jq -r '.ci' <<<"$output")" = null ]
git -C "$repo" remote set-url origin https://127.0.0.1:9/LotusGuild/alpha-https.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha-https ]
git -C "$repo" remote set-url origin https://github.com/someone/else.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = null ]
run devdesk repo main
[ "$(jq -r '.git' <<<"$output")" = false ]
}
@test "env.local overrides the packaged env" {
mkdir -p "$TMP/etc" "$DEVDESK_PROJECTS_ROOT/alpha/ops"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT" >"$TMP/etc/env"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT/alpha/ops" >"$TMP/etc/env.local"
export DEVDESK_ENV="$TMP/etc/env"
start_server
devdesk new adhoc
run tmux -S "$DEVDESK_SOCKET" display -p -t =adhoc:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ]
}
@test "state hook tracks working, waiting and idle per session" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; }
echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook
[ "$(state)" = working ]
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
[ "$(state)" = waiting ]
[ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ]
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook
[ "$(state)" = working ]
echo '{"hook_event_name":"Stop"}' | hook
[ "$(state)" = idle ]
echo '{"hook_event_name":"PreCompact"}' | hook
[ "$(state)" = idle ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ]
}
# Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts.
start_receiver() {
python3 - "$TMP" <<'PY' &
import http.server, sys
tmp = sys.argv[1]
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers['Content-Length']))
with open(f'{tmp}/posts', 'ab') as f:
f.write(body + b'\n')
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
def log_message(self, *a):
pass
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
open(f'{tmp}/port', 'w').write(str(srv.server_port))
srv.serve_forever()
PY
RECEIVER=$!
for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done
printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook"
chmod 600 "$TMP/webhook"
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook"
}
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
start_server
start_receiver
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
# a second notification in the same wait: no second message
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
sleep 2.5
[ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
echo '{"hook_event_name":"PostToolUse"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
# no webhook file: feature off
rm "$TMP/webhook"
echo '{"hook_event_name":"Notification","message":"again"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
kill "$RECEIVER"
}
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
start_server
# No tty (CLI, tests): no waiting.
start=$(date +%s%N)
DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null
[ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ]
# On a tty, as under ttyd: the reply is printed, then it waits. script's
# stdin stays open (like a browser that hasn't hung up); a closed stdin
# would hand the command EOF at once.
start=$(date +%s%N)
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
[[ $output == *'"sessions"'* ]]
[ "$elapsed" -ge 500 ]
# Errors are printed before the wait, too.
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
[[ $output == *"no session named"* ]]
}
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
run env DEVDESK_GRAFANA_URL= devdesk alerts
[ "$status" -eq 0 ]
[ "$(jq -r '.configured' <<<"$output")" = false ]
run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts
[ "$status" -eq 0 ]
[ "$(jq -r '.configured' <<<"$output")" = true ]
[ "$(jq -r '.error | length > 0' <<<"$output")" = true ]
}
@test "upload stores the exact bytes under a sanitised name" {
head -c 50001 /dev/urandom >"$TMP/in.bin"
run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001"
[ "$status" -eq 0 ]
path=$(tail -n1 <<<"$output" | jq -r .path)
[[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]]
cmp "$TMP/in.bin" "$path"
run bash -c "printf short | devdesk attach --upload alpha x.bin 100"
[ "$status" -ne 0 ]
[[ $output == *"upload incomplete"* ]]
run devdesk attach --upload alpha x.bin 999999999999
[ "$status" -ne 0 ]
run devdesk attach --upload ../x x.bin 10
[ "$status" -ne 0 ]
}
@test "end run from inside the session leaves no ghost registry entry (#3)" {
start_server
devdesk new alpha
tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter
for _ in $(seq 1 50); do
tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break
sleep 0.1
done
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "attach --existing refuses sessions that were ended or renamed (#4)" {
start_server
run devdesk attach --existing ghost
[ "$status" -ne 0 ]
[[ $output == *"no longer exists"* ]]
run devdesk attach --existing ../x
[ "$status" -ne 0 ]
}
@test "same-second uploads of the same name get distinct files (#6)" {
printf 'one' | base64 -w0 >"$TMP/a.b64"
printf 'two' | base64 -w0 >"$TMP/b.b64"
p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path)
p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path)
[ "$p1" != "$p2" ]
[ "$(cat "$p1")" = one ]
[ "$(cat "$p2")" = two ]
[[ $p1 == *-image.png ]]
}
@test "a tab in a project path does not break --list (#10)" {
mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name"
start_server
run devdesk attach --list
[ "$status" -eq 0 ]
jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null
}
@test "repo hides credentials embedded in the remote URL (#11)" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git
run devdesk repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ]
[[ $output != *hunter2* ]]
}
@test "web actions run through attach and answer with JSON" {
start_server
run devdesk attach --new web1 alpha
[ "$output" = '{"ok":true}' ]
run devdesk attach --rename web1 web2
[ "$output" = '{"ok":true}' ]
run devdesk attach --end web2
[ "$output" = '{"ok":true}' ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =web2
[ "$status" -ne 0 ]
run devdesk attach --bogus
[ "$status" -ne 0 ]
}
@test "session hook records the Claude session and the next start resumes it" {
start_server
2026-10-11 01:01:51 -04:00
id=abcabcab-1234-1234-1234-123412341234
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
echo "{\"session_id\":\"$id\",\"source\":\"startup\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
2026-10-11 01:01:51 -04:00
[ "$output" = "$id" ]
devdesk new alpha
run claude_args alpha
2026-10-11 01:01:51 -04:00
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
}
@test "a conversation whose file is gone starts fresh; a failed resume keeps the id (#12)" {
start_server
id=abcabcab-1234-1234-1234-123412341234
echo "{\"session_id\":\"$id\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
devdesk new alpha
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
tmux -S "$DEVDESK_SOCKET" kill-server
# The file exists, but Claude exits at once (Ctrl+C, network): the menu, not a new conversation.
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
rm -f "$FAKE_LOG/alpha"
FAKE_EXIT=1 start_server
devdesk new alpha
run claude_args alpha
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
for _ in $(seq 1 30); do
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" && break
sleep 0.1
done
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited"
[ "$(wc -l <"$FAKE_LOG/alpha")" = 1 ]
[ "$(jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = "$id" ]
}
@test "env files are parsed like systemd does, never run (#12)" {
cat >"$TMP/env" <<'ENV'
# comment
; also a comment
DEVDESK_DEFAULT_DIR=/srv/ops
CLAUDE_ARGS=--model opus --verbose
DEVDESK_PUBLIC_URL="https://dev.example.org"
DEVDESK_GRAFANA_URL='http://g:3000'
DEVDESK_MATRIX_DELAY=$(touch /tmp/devdesk-env-pwned)
lowercase=x
not a line
DEVDESK_BIND="unterminated
ENV
run env DEVDESK_ENV="$TMP/env" DEVDESK_ENV_LOCAL=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; printf "%s|" "$DEVDESK_DEFAULT_DIR" "$CLAUDE_ARGS" "$DEVDESK_PUBLIC_URL" "$DEVDESK_GRAFANA_URL" "$DEVDESK_MATRIX_DELAY" "${lowercase:-unset}" "${DEVDESK_BIND:-unset}"' _ "$REPO"
[ ! -e /tmp/devdesk-env-pwned ]
[[ $output == *'/srv/ops|--model opus --verbose|https://dev.example.org|http://g:3000|$(touch /tmp/devdesk-env-pwned)|unset|unset|'* ]]
[[ $output == *"env:8: not a KEY=value line"* ]]
[[ $output == *"env:10: unmatched quote"* ]]
}
@test "the SessionStart hook reads the session name under the rename lock (#12)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:claude '#{pane_id}')
# A rename holds the lock; the hook fires meanwhile, while tmux still says "alpha".
(
flock 9
sleep 0.5
tmux -S "$DEVDESK_SOCKET" rename-session -t =alpha beta
DEVDESK_ENV=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; DD_REGISTRY_LOCKED=1 dd_registry_rename alpha beta' _ "$REPO"
) 9>"$DEVDESK_STATE_DIR/registry.json.lock" 3>&- &
renamer=$!
sleep 0.1
echo '{"session_id":"ffffffff-0000-0000-0000-000000000000","source":"clear"}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE=$pane "$REPO/libexec/devdesk-session-hook"
wait "$renamer"
[ "$(jq -r '.sessions | keys | join(",")' "$DEVDESK_STATE_DIR/registry.json")" = beta ]
[ "$(jq -r '.sessions.beta.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = ffffffff-0000-0000-0000-000000000000 ]
}
@test "leftover temp files of killed writers are swept (#12)" {
mkdir -p "$DEVDESK_STATE_DIR/state" "$DEVDESK_STATE_DIR/context"
touch -d '20 minutes ago' "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" "$DEVDESK_STATE_DIR/registry.json.Qq11Rr"
touch "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" "$DEVDESK_STATE_DIR/state/alpha.json"
echo '{"session_id":"x"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
[ ! -e "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" ]
[ ! -e "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" ]
[ ! -e "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" ]
[ -e "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" ]
[ -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
}
@test "session hook ignores sessions it does not own" {
echo '{"session_id":"zzz"}' | DEVDESK_SESSION= "$REPO/libexec/devdesk-session-hook"
echo '{"session_id":"zzz"}' | DEVDESK_SESSION='../x' "$REPO/libexec/devdesk-session-hook"
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ]
}
@test "--list reports running, dormant and project entries" {
start_server
devdesk new alpha
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.default' <<<"$output")" = main ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .running' <<<"$output")" = true ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .running' <<<"$output")" = false ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .claude_session_id' <<<"$output")" = old-1 ]
[ "$(jq -r '[.projects[].name] | sort | join(",")' <<<"$output")" = "alpha,main,test-code" ]
}
@test "--list works with the server down" {
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions | length' <<<"$output")" = 0 ]
}
@test "end kills the session and forgets it" {
start_server
devdesk new alpha
run devdesk end alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
run jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = false ]
}
@test "remote control flag is passed when enabled" {
start_server
tmux -S "$DEVDESK_SOCKET" set-environment -g DEVDESK_REMOTE_CONTROL 1
devdesk new alpha
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ]
}
# Decode a blob reply (header line, base64 between markers) into $TMP/blob.
blob_body() {
sed -n '/^DEVDESK-BLOB-BEGIN$/,/^DEVDESK-BLOB-END$/p' <<<"$1" | sed '1d;$d' | base64 -d >"$TMP/blob"
}
@test "fs-list lists one level, folders first, and stays inside the session folder (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/src/deep" "$p/.git"
echo hi >"$p/README.md"
echo x >"$p/src/a.sh"
ln -s "$TMP/outside" "$p/escape-link"
ln -s src "$p/src-link"
run devdesk attach --fs-list alpha
[ "$status" -eq 0 ]
# Folders first (a symlink to a folder counts, even one that escapes: opening it fails).
[ "$(jq -r '[.entries[].n] | join(",")' <<<"$output")" = "escape-link,src,src-link,README.md" ]
run devdesk attach --fs-list alpha src
[ "$(jq -r '.dir + ":" + ([.entries[].n] | join(","))' <<<"$output")" = "src:deep,a.sh" ]
for bad in ../ /etc escape-link "src/../../alpha/../../outside"; do
run devdesk attach --fs-list alpha "$bad"
[ "$status" -ne 0 ]
done
run devdesk attach --fs-list alpha src-link
[ "$status" -eq 0 ]
}
@test "fs-read returns text, images and refuses binaries, big files and escapes (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'line 1\r\nüñí\n' >"$p/notes.md"
printf '\x89PNG\r\n\x1a\n\0\0\0' >"$p/pic.png"
head -c 100 /dev/zero >"$p/blob.bin"
head -c 3000 /dev/zero | tr '\0' a >"$p/big.txt"
run devdesk attach --fs-read alpha notes.md
[ "$status" -eq 0 ]
[ "$(head -n1 <<<"$output" | jq -r '[.path, .binary, .utf8, .body] | @tsv')" = "$(printf 'notes.md\tfalse\ttrue\ttrue')" ]
blob_body "$output"
cmp "$TMP/blob" "$p/notes.md"
run devdesk attach --fs-read alpha pic.png
[ "$(head -n1 <<<"$output" | jq -r '[.mime, .body] | @tsv')" = "$(printf 'image/png\ttrue')" ]
run devdesk attach --fs-read alpha blob.bin
[ "$(head -n1 <<<"$output" | jq -r '[.binary, .body] | @tsv')" = "$(printf 'true\tfalse')" ]
run env DEVDESK_FS_MAX=1000 "$REPO/bin/devdesk" attach --fs-read alpha big.txt
[ "$(head -n1 <<<"$output" | jq -r '.tooLarge')" = true ]
run devdesk attach --fs-read alpha ../alpha/../../outside
[ "$status" -ne 0 ]
}
@test "git-status and git-show report changes, line counts and Claude's edits (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
git -C "$p" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
printf 'one\ntwo\n' >"$p/kept.txt"
printf 'old\n' >"$p/moved.txt"
git -C "$p" add . && git -C "$p" -c user.name=t -c user.email=t@t commit -q -m files
printf 'one\nTWO\nthree\n' >"$p/kept.txt"
git -C "$p" mv moved.txt renamed.txt
echo new >"$p/fresh.txt"
mkdir -p "$DEVDESK_STATE_DIR/edits"
printf '%s\n' "$p/kept.txt" "$p/notes-outside-git.txt" >"$DEVDESK_STATE_DIR/edits/alpha.txt"
run devdesk attach --git-status alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.files | map("\(.x)\(.y) \(.path) +\(.add) -\(.del) \(.claude)") | sort | join("|")' <<<"$output")" = \
" notes-outside-git.txt +null -null true| M kept.txt +2 -1 true|?? fresh.txt +null -null false|R renamed.txt +null -null false" ]
[ "$(jq -r '.files[] | select(.path == "renamed.txt") | .from' <<<"$output")" = moved.txt ]
run devdesk attach --git-show alpha kept.txt
blob_body "$output"
[ "$(cat "$TMP/blob")" = "$(printf 'one\ntwo')" ]
run devdesk attach --git-show alpha fresh.txt
[ "$(head -n1 <<<"$output" | jq -r '.size')" = 0 ]
run devdesk attach --git-status alpha
run env DEVDESK_PROJECTS_ROOT="$TMP/outside" "$REPO/bin/devdesk" attach --git-status alpha
[ "$status" -eq 0 ]
}
@test "the state hook logs Claude's edits; rename moves and end removes the log (#19)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/x/n.ipynb"}}' | hook
[ "$(cat "$DEVDESK_STATE_DIR/edits/alpha.txt")" = "$(printf '/x/a.txt\n/x/n.ipynb')" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/edits/beta.txt" ] && [ ! -e "$DEVDESK_STATE_DIR/edits/alpha.txt" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/edits/beta.txt" ]
}
@test "asset serves only named .js files from the assets folder (#19)" {
mkdir -p "$TMP/assets"
echo 'window.X = 1;' >"$TMP/assets/editor.js"
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset editor.js
[ "$status" -eq 0 ]
blob_body "$output"
cmp "$TMP/blob" "$TMP/assets/editor.js"
for bad in ../etc/passwd editor.css nope.js; do
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset "$bad"
[ "$status" -ne 0 ]
done
}
@test "fs-list flags gitignored entries; fs-find lists files for go-to-file (#37)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
mkdir -p "$p/src" "$p/build" "$p/node_modules/x"
printf 'build/\n*.log\n' >"$p/.gitignore"
echo a >"$p/src/a.js"
echo b >"$p/debug.log"
echo c >"$p/node_modules/x/i.js"
run devdesk attach --fs-list alpha
[ "$(jq -r '[.entries[] | select(.ig) | .n] | join(",")' <<<"$output")" = "build,debug.log" ]
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,node_modules/x/i.js,src/a.js" ]
# Outside git: find, without node_modules
rm -rf "$p/.git"
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ]
# Hitting the cap must not fail the command (SIGPIPE in the producer).
for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src
[ "$status" -eq 0 ]
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
}
# Save through --fs-write the way the browser does: content as base64 on stdin.
fs_write() { # SESSION PATH BASE CONTENT-FILE
local size
size=$(stat -c %s "$4")
base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3"
}
@test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'v1\n' >"$p/conf.sh"
chmod 0750 "$p/conf.sh"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
printf 'v2\n' >"$TMP/new"
run fs_write alpha conf.sh "$base" "$TMP/new"
[ "$status" -eq 0 ]
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ "$(cat "$p/conf.sh")" = v2 ]
[ "$(stat -c %a "$p/conf.sh")" = 750 ]
[ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ]
# Someone (Claude) changed it since: the old base no longer matches.
printf 'v3 from me\n' >"$TMP/mine"
run fs_write alpha conf.sh "$base" "$TMP/mine"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ]
[ "$(cat "$p/conf.sh")" = v2 ]
# No temp files left behind
[ -z "$(find "$p" -name '*.devdesk-*')" ]
# Empty files are fine
: >"$TMP/empty"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
run fs_write alpha conf.sh "$base" "$TMP/empty"
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ ! -s "$p/conf.sh" ]
}
@test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'hello\n' >"$TMP/c"
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ]
[ "$(cat "$p/docs/notes/new.md")" = hello ]
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
ln -s "$TMP/outside" "$p/out"
for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do
run fs_write alpha "$bad" new "$TMP/c"
[ "$status" -ne 0 ]
done
[ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ]
# A short stream fails without touching anything
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)"
[ "$status" -ne 0 ]
[ "$(cat "$p/docs/notes/new.md")" = hello ]
}
@test "permission requests are recorded and answered only while their prompt shows (#22)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook
echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook
st="$DEVDESK_STATE_DIR/state/alpha.json"
[ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \
"$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ]
since=$(jq -r .since "$st")
# The fake Claude window shows no prompt: refused, nothing typed.
run devdesk attach --answer alpha "$since" allow
[ "$status" -ne 0 ]
[[ $output == *"isn't on screen"* ]]
# Put a prompt on screen (what Claude draws), then allow.
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30"
sleep 0.5
run devdesk attach --answer alpha 999 allow
[ "$status" -ne 0 ]
[[ $output == *"already answered"* ]]
run devdesk attach --answer alpha "$since" allow
[ "$status" -eq 0 ]
sleep 0.5
[ "$(cat "$TMP/key")" = "got:1" ]
[ "$(jq -r .state "$st")" = working ]
# Answered: a second click is refused.
run devdesk attach --answer alpha "$since" deny
[ "$status" -ne 0 ]
}
@test "peek shows the last exchange; reply pastes into the Claude window (#23)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=11111111-2222-3333-4444-555555555555
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"}
JSONL
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --peek alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ]
# reply: lands in the Claude window as one paste + Enter
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30"
sleep 0.3
printf 'yes, reboot it' >"$TMP/msg"
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
[ "$status" -eq 0 ]
sleep 0.5
[ "$(cat "$TMP/got")" = "yes, reboot it" ]
# not while a permission prompt waits
mkdir -p "$DEVDESK_STATE_DIR/state"
printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json"
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
[ "$status" -ne 0 ]
[[ $output == *"answer that first"* ]]
}
@test "the guard asks before destructive commands and lets the rest through (#27)" {
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
for c in "${asks[@]}"; do
run guard "$c"
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
done
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
for c in "${passes[@]}"; do
run guard "$c"
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
done
# Local rules and the off switch
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
[[ $output == *"is a local rule"* ]]
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
[ -z "$output" ]
}
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
bash_cmd "ls -la"
bash_cmd "ssh pve2 qm status 210"
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
run devdesk attach --peek alpha
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
}
# A fake Claude history: one conversation in $1 with id $2.
fake_convo() {
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p"
jq -cn --arg cwd "$1" --arg id "$2" '
{type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}},
{type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}},
{type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \
>"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl"
}
@test "search finds past conversations by what was said and run (#24)" {
export CLAUDE_CONFIG_DIR="$TMP/claude"
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111
run devdesk attach --search "no_root_squash"
[ "$status" -eq 0 ]
[ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \
"$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ]
run devdesk attach --search "MOUNT -t NFS"
[ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ]
run devdesk attach --search "nothing like this anywhere"
[ "$(jq -r '.results | length' <<<"$output")" = 0 ]
run devdesk attach --search "x"
[ "$status" -ne 0 ]
}
@test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" {
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=22222222-2222-2222-2222-222222222222
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id"
start_server
run devdesk attach --resume-convo nfs-fix "$id" fork
[ "$status" -eq 0 ]
claude_args nfs-fix >/dev/null
[[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]]
[ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
[ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ]
# Resuming (not forking) the same conversation while it's open elsewhere is refused.
jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --resume-convo nfs-again "$id"
[ "$status" -ne 0 ]
[[ $output == *"open in session nfs-fix"* ]]
run devdesk attach --resume-convo nfs-fix "$id" fork
[ "$status" -ne 0 ]
[[ $output == *"already running"* ]]
}
@test "presence marks a session while a tab shows it (#26)" {
export DEVDESK_PRESENCE_DIR="$TMP/presence"
start_server
devdesk new alpha
claude_args alpha >/dev/null
sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}')
run devdesk attach --presence alpha on
[ "$status" -eq 0 ]
[ -e "$TMP/presence/${sid#\$}" ]
# The Claude window was told where the marker lives.
[ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session
run devdesk attach --presence alpha off
[ ! -e "$TMP/presence/${sid#\$}" ]
}