2026-10-06 23:11:07 -04:00
|
|
|
|
#!/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
|
2026-10-07 23:34:59 -04:00
|
|
|
|
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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"
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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
|
2026-10-07 03:03:09 -04:00
|
|
|
|
for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do
|
2026-10-06 23:11:07 -04:00
|
|
|
|
run devdesk attach "$bad"
|
|
|
|
|
|
[ "$status" -ne 0 ]
|
|
|
|
|
|
[[ $output == *"invalid session name"* ]]
|
|
|
|
|
|
done
|
2026-10-07 03:03:09 -04:00
|
|
|
|
run devdesk attach -x
|
|
|
|
|
|
[ "$status" -ne 0 ]
|
|
|
|
|
|
[[ $output == *"unknown option"* ]]
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
|
@test "symlinks may not escape the projects root" {
|
|
|
|
|
|
start_server
|
|
|
|
|
|
run devdesk new escape
|
2026-10-07 03:03:09 -04:00
|
|
|
|
[ "$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 ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 12:54:28 -04:00
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 13:09:32 -04:00
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
|
# 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"
|
2026-10-10 19:57:52 -04:00
|
|
|
|
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
|
2026-10-07 23:34:59 -04:00
|
|
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
|
@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 ]
|
2026-10-08 00:36:50 -04:00
|
|
|
|
# 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.
|
2026-10-08 00:32:23 -04:00
|
|
|
|
start=$(date +%s%N)
|
2026-10-08 00:36:50 -04:00
|
|
|
|
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
|
2026-10-08 00:32:23 -04:00
|
|
|
|
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
|
|
|
|
|
|
[[ $output == *'"sessions"'* ]]
|
|
|
|
|
|
[ "$elapsed" -ge 500 ]
|
|
|
|
|
|
# Errors are printed before the wait, too.
|
2026-10-08 00:36:50 -04:00
|
|
|
|
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
|
2026-10-08 00:32:23 -04:00
|
|
|
|
[[ $output == *"no session named"* ]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
|
@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 ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 20:15:47 -04:00
|
|
|
|
@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* ]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
@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
|
2026-10-06 23:11:07 -04:00
|
|
|
|
[ "$status" -ne 0 ]
|
2026-10-07 03:03:09 -04:00
|
|
|
|
run devdesk attach --bogus
|
2026-10-06 23:11:07 -04:00
|
|
|
|
[ "$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"
|
2026-10-06 23:11:07 -04:00
|
|
|
|
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
|
2026-10-11 01:01:51 -04:00
|
|
|
|
[ "$output" = "$id" ]
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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" ]
|
2026-10-06 23:11:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
2026-10-08 01:16:37 -04:00
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
}
|
2026-10-08 01:40:47 -04:00
|
|
|
|
|
|
|
|
|
|
@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" ]
|
2026-10-08 01:50:50 -04:00
|
|
|
|
# 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
|
2026-10-08 01:40:47 -04:00
|
|
|
|
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
|
2026-10-08 01:50:50 -04:00
|
|
|
|
[ "$status" -eq 0 ]
|
2026-10-08 01:40:47 -04:00
|
|
|
|
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
|
2026-10-08 01:50:50 -04:00
|
|
|
|
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')" ]
|
2026-10-08 01:40:47 -04:00
|
|
|
|
}
|
2026-10-10 20:10:45 -04:00
|
|
|
|
|
|
|
|
|
|
# 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 ]
|
|
|
|
|
|
}
|
2026-10-10 20:22:37 -04:00
|
|
|
|
|
|
|
|
|
|
@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"* ]]
|
|
|
|
|
|
}
|
2026-10-10 20:37:46 -04:00
|
|
|
|
|
|
|
|
|
|
@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" ]
|
|
|
|
|
|
}
|
2026-10-10 21:45:26 -04:00
|
|
|
|
|
|
|
|
|
|
# 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#\$}" ]
|
|
|
|
|
|
}
|
2026-10-11 11:24:49 -04:00
|
|
|
|
|
|
|
|
|
|
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
|
|
|
|
|
|
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
|
|
|
|
|
|
fake_pve() {
|
|
|
|
|
|
export FAKE_PVE="$TMP/pve" DEVDESK_PVE_SSH="$TMP/fake-ssh" DEVDESK_PVE_NODES="down-node large1"
|
|
|
|
|
|
mkdir -p "$FAKE_PVE"
|
|
|
|
|
|
cat >"$DEVDESK_PVE_SSH" <<'EOF'
|
|
|
|
|
|
#!/bin/bash
|
|
|
|
|
|
while [ "${1:-}" = -o ]; do shift 2; done
|
|
|
|
|
|
case $1:$2 in
|
|
|
|
|
|
down-node:*) exit 255 ;;
|
|
|
|
|
|
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
|
|
|
|
|
|
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
EOF
|
|
|
|
|
|
chmod +x "$DEVDESK_PVE_SSH"
|
|
|
|
|
|
pve_state running
|
|
|
|
|
|
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >"$FAKE_PVE/node-large1"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
|
|
|
|
|
|
pve_state() {
|
|
|
|
|
|
printf '%s\n' \
|
|
|
|
|
|
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"'"$1"'","type":"lxc","hastate":"'"${1/running/started}"'"},' \
|
|
|
|
|
|
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
|
|
|
|
|
|
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
|
|
|
|
|
|
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
|
|
|
|
|
|
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >"$FAKE_PVE/inventory"
|
|
|
|
|
|
if [ "$1" = running ]; then
|
|
|
|
|
|
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >"$FAKE_PVE/node-monitor-02"
|
|
|
|
|
|
else
|
|
|
|
|
|
printf 'monitor-02\t10.10.10.9\n' >"$FAKE_PVE/node-monitor-02"
|
|
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Grafana's rules API, served from a file.
|
|
|
|
|
|
fake_grafana() {
|
|
|
|
|
|
export DEVDESK_GRAFANA_URL="file://$TMP/grafana" DEVDESK_GRAFANA_PUBLIC_URL=https://grafana.example
|
|
|
|
|
|
mkdir -p "$TMP/grafana/api/prometheus/grafana/api/v1"
|
|
|
|
|
|
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
|
|
|
|
|
|
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
|
|
|
|
|
|
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
|
|
|
|
|
|
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
|
|
|
|
|
|
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
|
|
|
|
|
|
annotations: {summary: "status={{ $value }}"},
|
|
|
|
|
|
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
|
|
|
|
|
|
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
|
|
|
|
|
|
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
|
|
|
|
|
|
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
|
|
|
|
|
|
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
|
|
|
|
|
|
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >"$TMP/grafana/api/prometheus/grafana/api/v1/rules"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
|
|
|
|
|
|
fake_pve
|
|
|
|
|
|
run devdesk guests
|
|
|
|
|
|
[ "$status" -eq 0 ]
|
|
|
|
|
|
[[ $output == *"110"*"draupnir"*"monitor-02"*"running"*"started"*"10.10.10.24"* ]]
|
|
|
|
|
|
[[ $output == *"102"*"vm"*"homeAssistant"*"large1"*"10.10.10.23"* ]]
|
|
|
|
|
|
[[ $output != *"127.0.0.1"* ]]
|
|
|
|
|
|
run devdesk guests --json 10.10.10.27
|
|
|
|
|
|
[ "$(jq -r '[.guests[].id] | join(",")' <<<"$output")" = 139 ]
|
|
|
|
|
|
[ "$(jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<<"$output")" = 10.10.10.2,10.10.90.10 ]
|
|
|
|
|
|
# Draupnir stops: its IP is remembered, marked as last seen.
|
|
|
|
|
|
pve_state stopped
|
|
|
|
|
|
run devdesk guests drau
|
|
|
|
|
|
[[ $output == *"stopped"*"10.10.10.24 (last seen)"* ]]
|
|
|
|
|
|
# No node answers: the cache, with a warning.
|
|
|
|
|
|
run env DEVDESK_PVE_NODES=down-node devdesk guests draupnir
|
|
|
|
|
|
[ "$status" -eq 0 ]
|
|
|
|
|
|
[[ $output == *"no Proxmox node answered"*"10.10.10.24"* ]]
|
|
|
|
|
|
run env DEVDESK_PVE_NODES= devdesk guests
|
|
|
|
|
|
[ "$status" -ne 0 ]
|
|
|
|
|
|
[[ $output == *"DEVDESK_PVE_NODES"* ]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@test "alerts name the guest or node each one is about (#39)" {
|
|
|
|
|
|
fake_pve
|
|
|
|
|
|
fake_grafana
|
|
|
|
|
|
devdesk guests >/dev/null
|
|
|
|
|
|
pve_state stopped
|
|
|
|
|
|
devdesk guests >/dev/null
|
|
|
|
|
|
run devdesk alerts
|
|
|
|
|
|
[ "$status" -eq 0 ]
|
|
|
|
|
|
t() { jq -c --arg u "$1" '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<<"$output"; }
|
|
|
|
|
|
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
|
|
|
|
|
|
[ "$(t u-ceph)" = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
|
|
|
|
|
|
[ "$(t u-count)" = '[]' ]
|
|
|
|
|
|
[ "$(t u-lan)" = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
|
|
|
|
|
|
[ "$(jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<<"$output")" = "status=1" ]
|
|
|
|
|
|
[ "$(jq -r '[.alerts[].uid] | join(",")' <<<"$output")" = u-draupnir,u-ceph,u-count,u-lan ]
|
|
|
|
|
|
# Never seen running: matched by the name in its job label instead.
|
|
|
|
|
|
rm "$DEVDESK_STATE_DIR/guests.json"
|
|
|
|
|
|
devdesk guests >/dev/null
|
|
|
|
|
|
run devdesk alerts
|
|
|
|
|
|
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
|
|
|
|
|
|
fake_pve
|
|
|
|
|
|
fake_grafana
|
|
|
|
|
|
devdesk guests >/dev/null
|
|
|
|
|
|
start_server
|
|
|
|
|
|
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
|
|
|
|
|
[ "$status" -eq 0 ]
|
|
|
|
|
|
[ "$(jq -c . <<<"$output")" = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
|
|
|
|
|
|
claude_args alert-draupnir-down >/dev/null
|
|
|
|
|
|
log="$FAKE_LOG/alert-draupnir-down"
|
|
|
|
|
|
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' "$log"
|
|
|
|
|
|
grep -q 'Alert: Draupnir Down (critical, firing since' "$log"
|
|
|
|
|
|
grep -q 'Query: `up{job="draupnir-node"}`' "$log"
|
|
|
|
|
|
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' "$log"
|
|
|
|
|
|
grep -q '`devdesk guests 110` shows where it is now' "$log"
|
|
|
|
|
|
# The prompt is sent once: not again when Claude is resumed.
|
|
|
|
|
|
[ ! -e "$DEVDESK_STATE_DIR/prompts/alert-draupnir-down.txt" ]
|
|
|
|
|
|
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
|
|
|
|
|
[ "$(jq -r .started <<<"$output")" = false ]
|
|
|
|
|
|
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
|
|
|
|
|
|
run devdesk attach --investigate exporters u-count
|
|
|
|
|
|
claude_args exporters >/dev/null
|
|
|
|
|
|
grep -q 'The rule aggregates' "$FAKE_LOG/exporters"
|
|
|
|
|
|
run grep -c 'Where it runs' "$FAKE_LOG/exporters"
|
|
|
|
|
|
[ "$output" = 0 ]
|
|
|
|
|
|
run devdesk attach --investigate gone u-ok
|
|
|
|
|
|
[ "$status" -ne 0 ]
|
|
|
|
|
|
[[ $output == *"no longer firing"* ]]
|
|
|
|
|
|
run devdesk attach --investigate 'bad name' u-ok
|
|
|
|
|
|
[[ $output == *"invalid session name"* ]]
|
|
|
|
|
|
}
|