Files
dejima/test/remote.bats
T
branderson 299febef02
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (providers) (pull_request_target) Successful in 1m49s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / confined (remote) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 3m59s
tests / confined (project) (pull_request_target) Successful in 4m5s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (config) (pull_request_target) Successful in 5m44s
tests / confined (audit) (pull_request_target) Successful in 7m16s
Add shellcheck and real tests for the shipper, and close the staleness gap
P4.17, P4.15, P4.16 and P3.14 from the codebase audit.

shellcheck now runs over all 29 shell scripts in the repo as its own CI job,
at -S warning. test/shellcheck.sh holds the file list, discovered by shebang
rather than enumerated, so a script added under bin/, hooks/, docker/ or a
forge's bin/ is covered the day it lands; the job and the static test that
pins its coverage call the same script.

It found a real bug on the first run. _provider_keys_cached did

    local axis="$1" name="$2" cache_key="$axis:$name"

and bash does not apply the earlier assignments before evaluating the later
one in a single `local`, so cache_key was built from whatever axis and name
meant in the CALLER's scope. The cache was keyed on ":" for every provider,
and the only reason nothing broke is that both call sites wrap it in $( ) -
so the cache was populated in a throwaway subshell, read back never, and its
comment described a win that could not happen. It is removed rather than
fixed: a per-command-substitution cache with one lookup per subshell is dead
code, and dead code with a latent collision in it is worse than none.

Six `export X="$(cmd)"` in bin/dejima are split into an assignment and an
export. export always succeeds, so the combined form hides a failing
substitution from set -e; split, a config_get that fails stops the launch.

The rest of what shellcheck flagged is intentional, and says so at the site:
one-row tables that read as loops, literal tildes in prose, a trap body baked
in on purpose, and mkdir -p -m whose leaf-only mode is the reason for the
verify-and-chmod block beneath it. Two classes are disabled repo-wide in
.shellcheckrc, both about variables crossing a boundary shellcheck cannot
see: a library defining what its consumers read, and status=$? inside a
single-quoted trap. Verified clean under 0.11.0 and under the 0.9.0 CI will
install.

test/conversation.bats replaces grep-based assertions about
bin/dejima-conversation-ship with eight behavioural ones: the shipper runs
against a real transcript and a real HTTP endpoint standing in for Loki, and
what arrives is what is asserted on. Mutation-tested both ways - removing the
TERM trap fails the two loss-path tests, and a final_flush that flushes the
batch without rescanning fails only the tail test, which is the distinction a
grep cannot make. The superseded grep test is removed.

The web/index.html staleness test compared the manifest against the tree and
nothing against the page, so a commit staging web/src and web/sources.sha256
while leaving index.html behind passed and shipped the stale page - the exact
failure its own comment claimed to prevent. build.mjs already stamps the page
with sha256sum of the manifest it wrote; the test now checks that too.
Verified by reproducing the stale-page commit, which fails, and the correct
one, which passes. build.mjs pointed at test/static.bats for this; the test
is in test/remote.bats.

One static test hardcoded "exactly 2 checkouts pin the head sha", which the
new job broke. It asserts the property now - every checkout is pinned except
the two runner-env jobs - since a count breaks when a job is added, which is
when nobody is thinking about which ref it reads.

All eight host-side suites pass, and shellcheck is clean.
2026-08-19 17:49:03 -07:00

2977 lines
134 KiB
Bash

#!/usr/bin/env bats
# Host-side tests for the opt-in remote access feature: config parsing, the
# session registry, the attach core, and the notification watcher. The
# watcher is a trust boundary - it consumes a directory a possibly
# compromised session writes into - so most of what's here is about what it
# refuses to do with that input.
# No test in this file may modify the repository's own CLIs. Two of them need
# an exit-time audit that fails, and they get it from a temp shadow of bin/
# (see shadow_repo) rather than by overwriting the real one. This is what
# proves it stayed that way even if a test aborts partway through, and it is
# checked against a hash rather than against `git status` so that it holds
# while someone has uncommitted edits to that file.
setup_file() {
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
export REMOTE_AUDIT_SUM
}
teardown_file() {
local now
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
return 1
}
}
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
RUNTIME_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
# never the operator's.
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
# Writes a [remote-access] config with enabled = on and base_url set (so
# remote_enabled is true) plus whatever extra key = value lines a test needs.
write_remote_config() {
{ printf '[remote-access]\n'; printf 'enabled = on\n'; printf 'base_url = https://d.example\n'; printf '%s\n' "$@"; } \
> "$CONFIG_DIR/config"
}
@test "remote_enabled is false until enabled is on, independent of base_url" {
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib remote_enabled
[ "$status" -ne 0 ]
# base_url alone is not the switch - it never was, and consolidation must
# not make it one.
printf '[remote-access]\nbase_url = https://d.example\n' > "$CONFIG_DIR/config"
run lib remote_enabled
[ "$status" -ne 0 ]
printf '[remote-access]\nenabled = on\n' > "$CONFIG_DIR/config"
run lib remote_enabled
[ "$status" -eq 0 ]
}
@test "valid_run_id accepts what bin/dejima generates and nothing else" {
run lib valid_run_id 0123456789abcdef
[ "$status" -eq 0 ]
for bad in '' 'ABCDEF0123456789' '0123456789abcde' '0123456789abcdef0' \
'../../etc/passwd' '0123456789abcde;'; do
run lib valid_run_id "$bad"
[ "$status" -ne 0 ]
done
}
@test "remote_state_dir honors XDG_RUNTIME_DIR" {
run lib remote_state_dir
[ "$output" = "$RUNTIME_DIR/dejima" ]
}
# A throwaway copy of the repository whose bin/dejima-audit a test may
# replace or delete. Everything else is a symlink to the real thing, so this
# costs one directory of links.
#
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
# resolves its own location with `readlink -f`, so a symlinked copy would
# point SCRIPT_DIR back at the repository and the stub would never be the one
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
# dejima-audit` follows it and truncates the real script, which is precisely
# what this helper exists to stop. Copying removes the whole class rather
# than one instance of it.
#
# Two tests here need an audit binary that fails, and they used to get one by
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
# left the working tree holding a two-line stub for the length of the test,
# which is a poor thing to do to a security tool: a run killed partway, or
# two runs of this file at once, restores the stub permanently instead of the
# script, and a suite that passes while silently disabling the exit-time
# audit is the exact failure this file exists to prevent. It also, reasonably
# enough, reads like an attack to anything watching the repository for
# changes.
shadow_repo() {
local root entry
root="$(cd "$REPO_ROOT" && pwd)"
SHADOW="$BATS_TEST_TMPDIR/shadow"
mkdir -p "$SHADOW"
for entry in "$root"/*; do
[ "$(basename "$entry")" = bin ] && continue
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
done
cp -a "$root/bin" "$SHADOW/bin"
DEJIMA_BIN="$SHADOW/bin/dejima"
}
# Stands in for the real audit and does nothing but fail. Self-describing
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
# where a security tool belongs is alarming without this comment on it.
shadow_failing_audit() {
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
#!/bin/bash
# Test fixture for test/remote.bats, living in that test's temp directory
# and never in the repository. Fails on purpose: bin/dejima must still
# report the container's exit code and not this one.
exit 3
EOF
chmod +x "$SHADOW/bin/dejima-audit"
}
# A fake `docker` that records its argv, so bin/dejima's launch path can be
# exercised with no daemon. Same approach as test/project.bats.
stub_launch() {
LAUNCH_DIR="$(mktemp -d)"
mkdir -p "$LAUNCH_DIR/home/.claude"
# Seeded with hooks the operator already had, not an empty object: the
# per-session registration appends to whatever Notification/Stop entries
# are there, and against `{}` there is nothing to lose, so replacing
# instead of appending would look identical. Kept as a pristine copy too,
# to assert the real file is left byte-for-byte alone.
cat > "$LAUNCH_DIR/settings-seed.json" <<'EOF'
{
"hooks": {
"Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "operator-desktop-notify.sh"}]}],
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "operator-tts.sh"}]}]
}
}
EOF
cp "$LAUNCH_DIR/settings-seed.json" "$LAUNCH_DIR/home/.claude/settings.json"
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# Snapshot the generated settings copy while it still exists - bin/dejima's
# EXIT trap deletes it. It has to come from the exported
# $SANDBOX_SETTINGS_FILE rather than from a -v argument, because that mount
# is declared in docker-compose.yml and so never appears in this argv (same
# reason test/project.bats reads the run environment).
for arg in "$@"; do
[ "$arg" = run ] || continue
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
break
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
# ssh_key is written into whatever config file config_root() currently
# resolves to and appended rather than written, so it layers over whatever
# write_remote_config already put there instead of clobbering it - and so it
# still creates the file at all when a test calls launch() with no
# write_remote_config call.
launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
@test "with remote access unconfigured the compose invocation is unchanged" {
# The opt-in guarantee, tested rather than asserted: an operator who has
# not configured this feature must get byte-identical behavior.
stub_launch
launch "$PROJ"
baseline="$(run_line)"
[ -n "$baseline" ]
case "$baseline" in
*DEJIMA_RUN_ID*|*dejima-notify*) false ;;
esac
[ ! -d "$RUNTIME_DIR/dejima" ]
# The settings copy is off-argv (docker-compose.yml declares that mount),
# so an unchanged run line alone would not notice a notify hook being
# registered into it. Assert it was actually captured first - grep on a
# missing file also "finds nothing".
[ "$(jq '.sandbox.enabled' "$LAUNCH_DIR/settings-copy.json")" = "false" ]
run grep -q 'dejima-notify' "$LAUNCH_DIR/settings-copy.json"
[ "$status" -ne 0 ]
rm -rf "$PROJ"
}
@test "with remote access configured a registry entry is written and removed on exit" {
stub_launch
write_remote_config
launch "$PROJ"
# The EXIT trap removes it, so the directory exists but is empty by now.
[ -d "$RUNTIME_DIR/dejima/sessions" ]
[ -z "$(ls -A "$RUNTIME_DIR/dejima/sessions")" ]
[ -z "$(ls -A "$RUNTIME_DIR/dejima/notify")" ]
rm -rf "$PROJ"
}
@test "the mounted marker directory is this run's own, and the registry is never mounted" {
stub_launch
write_remote_config
launch "$PROJ"
line="$(run_line)"
# The run ID appears as the LAST path segment of the mount source: the
# container gets its own directory, never the parent, so it cannot
# fabricate markers for another session.
[[ "$line" =~ -v\ [^[:space:]]*/notify/([0-9a-f]{16}):/run/dejima-notify ]]
[[ "$line" =~ -e\ DEJIMA_RUN_ID=([0-9a-f]{16}) ]]
[[ "$line" =~ -e\ DEJIMA_NOTIFY_DIR=/run/dejima-notify ]]
# The registry decides which pane an operator gets attached to. A session
# able to write it could redirect them, so it is never mounted.
#
# Matched on the registry's own path, not on the bare word "sessions":
# the harness has a ~/.claude/sessions of its own, and once that mount
# moved from docker-compose.yml onto the command line this read as the
# registry being mounted when it was not.
case "$line" in
*/dejima/sessions*) false ;;
esac
rm -rf "$PROJ"
}
@test "a session cannot rewrite Claude Code's exit code through its marker directory" {
# /run/dejima-notify is bind-mounted read-write, so a session can leave it
# undeletable, and that rm -rf is the LAST command of bin/dejima's EXIT
# trap - where under set -e a failure replaces the shell's exit status
# with 1. Both halves are the session's to control, so without the guard a
# compromised sandbox reports its own status in place of the real one.
stub_launch
write_remote_config
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
for arg in "$@"; do
case "$arg" in
*:/run/dejima-notify)
dir="${arg%:/run/dejima-notify}"
mkdir -p "$dir/undeletable"
: > "$dir/undeletable/file"
chmod 500 "$dir/undeletable"
exit 3
;;
esac
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
run launch "$PROJ"
[ "$status" -eq 3 ]
# Left behind rather than deleted, and said so out loud.
[[ "$output" == *"could not remove the marker directory"* ]]
# teardown's rm -rf would inherit the same refusal.
chmod -R u+rwX "$RUNTIME_DIR"
rm -rf "$PROJ"
}
@test "the exit-time audit still fires and prints after a forced marker-directory removal failure, without eating the real exit code" {
# Task 10: cleanup() now runs the exit-time audit after the marker-
# directory removal, structurally, so the operator's bottom-up read of
# the terminal always lands on the verdict. Stack this session's own
# provokable print (the marker-directory warning above) with a real
# workspace change and a distinct container exit code, and check all
# three survive together: the warning prints, the audit's report prints
# strictly after it, and the container's own exit code (not 1, not the
# audit's own status) is what bin/dejima reports.
stub_launch
write_remote_config
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
for arg in "$@"; do
case "$arg" in
*:/run/dejima-notify)
dir="${arg%:/run/dejima-notify}"
mkdir -p "$dir/undeletable"
: > "$dir/undeletable/file"
chmod 500 "$dir/undeletable"
echo '{}' > "$SANDBOX_TEST_WS/.mcp.json"
exit 5
;;
esac
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
export SANDBOX_TEST_WS="$PROJ"
run launch "$PROJ"
unset SANDBOX_TEST_WS
[ "$status" -eq 5 ]
marker_line=$(printf '%s\n' "$output" | grep -n 'could not remove the marker directory' | head -1 | cut -d: -f1)
audit_line=$(printf '%s\n' "$output" | grep -n 'HOST-CODE-EXECUTION workspace file: \.mcp\.json' | head -1 | cut -d: -f1)
verdict_line=$(printf '%s\n' "$output" | grep -n 'CRITICAL - code that will run on your host' | head -1 | cut -d: -f1)
[ -n "$marker_line" ]
[ -n "$audit_line" ]
[ -n "$verdict_line" ]
[ "$marker_line" -lt "$audit_line" ]
# The verdict is the true last line of everything bin/dejima printed -
# not just after the marker warning, but after every other line too.
total_lines=$(printf '%s\n' "$output" | wc -l)
[ "$verdict_line" -eq "$total_lines" ]
chmod -R u+rwX "$RUNTIME_DIR"
rm -rf "$PROJ"
}
@test "the container's real exit code survives an exit-time audit invocation that itself fails" {
# The brief's central question: cleanup() now calls dejima-audit compare
# from the EXIT trap tail, guarded with || true specifically so that a
# failing audit invocation cannot replace the shell's own exit status
# under set -e. Prove it directly rather than by reasoning alone: swap
# in an audit binary that exits nonzero, and confirm bin/dejima still
# reports the container's own distinctive exit code (5 - neither 0, nor
# 1, nor the audit stub's own failure code of 3).
stub_launch
write_remote_config
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
for arg in "$@"; do
[ "$arg" = run ] && exit 5
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
shadow_repo
shadow_failing_audit
run launch "$PROJ"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@test "the container's real exit code survives an exit-time audit invocation that does not exist" {
# Same guarantee, second failure mode named by the brief: the audit
# binary missing entirely (a broken install, a bad upgrade) makes the
# shell's own exec fail with 127 at that point in cleanup() - still
# guarded by the same || true, so it must not surface as bin/dejima's
# reported exit code either.
stub_launch
write_remote_config
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
for arg in "$@"; do
[ "$arg" = run ] && exit 5
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
shadow_repo
rm -f "$SHADOW/bin/dejima-audit"
run launch "$PROJ"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@test "an undeletable entry in SANDBOX_GITCONFIG_DIR does not abort the exit trap or eat the real exit code" {
# CRITICAL 2: SANDBOX_GITCONFIG_DIR is bind-mounted read-write into the
# container (docker-compose.yml, no :ro), unconditionally for every
# session - unlike the notify marker directory above, this one exists
# whether or not remote access is even configured. A session can leave
# an undeletable subdirectory in it, making the host-side `rm -rf` fail;
# before the `|| true` guard, that failure would abort cleanup()'s EXIT
# trap right there under set -e, so dejima-audit compare never runs and
# bin/dejima would report 1 in place of the container's real exit code.
# Prove both halves survive together: the audit still runs (a workspace
# change it plants is still reported) and the container's own
# distinctive exit code (7 - neither 0 nor 1) comes through unchanged.
stub_launch
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$SANDBOX_GITCONFIG_DIR" > "$LAUNCH_DIR/gitconfig-dir-path"
mkdir -p "\$SANDBOX_GITCONFIG_DIR/undeletable"
: > "\$SANDBOX_GITCONFIG_DIR/undeletable/file"
chmod 500 "\$SANDBOX_GITCONFIG_DIR/undeletable"
echo '{}' > "\$SANDBOX_TEST_WS/.mcp.json"
exit 7
EOF
chmod +x "$LAUNCH_DIR/docker"
export SANDBOX_TEST_WS="$PROJ"
run launch "$PROJ"
unset SANDBOX_TEST_WS
[ "$status" -eq 7 ]
[[ "$output" == *"HOST-CODE-EXECUTION workspace file: .mcp.json"* ]]
gitconfig_dir="$(cat "$LAUNCH_DIR/gitconfig-dir-path")"
[ -n "$gitconfig_dir" ]
[ -d "$gitconfig_dir" ] # left behind, not removed - the guard doesn't pretend it succeeded
chmod -R u+rwX "$gitconfig_dir"
rm -rf "$gitconfig_dir" "$PROJ"
}
@test "a sandbox started outside tmux records an empty pane rather than being skipped" {
stub_launch
write_remote_config
# The EXIT trap removes the entry, so snapshot it from inside the run:
# the stub stands in for the container, and runs while it still exists.
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
rm -rf "$LAUNCH_DIR/sessions-snapshot"
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
TMUX_PANE= launch "$PROJ"
entry="$(cat "$LAUNCH_DIR"/sessions-snapshot/*)"
[[ "$entry" == *"workspace=$PROJ"* ]]
[[ "$entry" == *"pane="* ]]
[[ "$entry" =~ started=[0-9]+ ]]
rm -rf "$PROJ"
}
@test "a workspace path containing a newline is refused" {
# A newline could inject an extra key=value line into the flat registry
# entry (e.g. a forged pane=), so a path that can't be represented safely
# is a hard failure, not silently sanitized.
stub_launch
write_remote_config
bad_proj="$PROJ"/$'line1\nline2'
mkdir -p "$bad_proj"
run launch "$bad_proj"
[ "$status" -ne 0 ]
[ -z "$(ls -A "$RUNTIME_DIR/dejima/sessions" 2>/dev/null)" ]
rm -rf "$PROJ"
}
@test "a pre-existing symlink at the state dir is refused, not followed" {
# /tmp (the fallback parent when XDG_RUNTIME_DIR is unset) is
# world-writable, and `mkdir -p -m 700` only sets the mode of the leaf it
# creates - a planted symlink at the state dir itself must be refused,
# not followed into wherever it points.
stub_launch
write_remote_config
elsewhere="$(mktemp -d)"
ln -s "$elsewhere" "$RUNTIME_DIR/dejima"
run launch "$PROJ"
[ "$status" -ne 0 ]
[ -z "$(ls -A "$elsewhere")" ]
rm -rf "$elsewhere" "$PROJ"
}
@test "an inherited DEJIMA_NOTIFY_DIR is not deleted when remote access is off" {
# DEJIMA_NOTIFY_DIR is also the name of a container-facing env var; if it
# happens to be set in the launching shell for any other reason, a run
# with the feature off must not treat it as its own and delete it.
stub_launch
survive_parent="$(mktemp -d)"
survive="$survive_parent/must-survive"
mkdir -p "$survive"
export DEJIMA_NOTIFY_DIR="$survive"
# Same hazard for the settings-rewrite scratch file the remote block
# uses: cleanup() rm -f's it, so an inherited value must not be believed
# either.
survive_file="$survive_parent/must-survive-too"
: > "$survive_file"
export SANDBOX_REMOTE_SETTINGS_TMP="$survive_file"
# The same hazard for every other name cleanup() removes but only assigns
# inside a conditional block. This run has no nvim config and no approved
# dependencies, so none of the three is ever set - and one of them is an
# `rm -rf` argument, which takes a whole tree, not a file.
survive_lock="$survive_parent/must-survive-lock"
survive_dockerfile="$survive_parent/must-survive-dockerfile"
survive_ctx="$survive_parent/must-survive-ctx"
: > "$survive_lock"
: > "$survive_dockerfile"
mkdir -p "$survive_ctx/deep"
: > "$survive_ctx/deep/canary"
export SANDBOX_NVIM_LOCKFILE="$survive_lock"
export SANDBOX_DERIVED_DOCKERFILE="$survive_dockerfile"
export SANDBOX_DERIVED_CONTEXT="$survive_ctx"
launch "$PROJ"
unset DEJIMA_NOTIFY_DIR SANDBOX_REMOTE_SETTINGS_TMP SANDBOX_NVIM_LOCKFILE \
SANDBOX_DERIVED_DOCKERFILE SANDBOX_DERIVED_CONTEXT
[ -d "$survive" ]
[ -f "$survive_file" ]
[ -f "$survive_lock" ]
[ -f "$survive_dockerfile" ]
[ -f "$survive_ctx/deep/canary" ]
rm -rf "$survive_parent" "$PROJ"
}
@test "an inherited DEJIMA_NOTIFY_DIR is not deleted on an early exit before remote access is evaluated" {
# trap cleanup EXIT is installed long before the remote-access block ever
# runs - several unrelated `exit 1` paths sit in between (a bad
# mounts.readonly entry is one), and the trap must not delete an
# inherited DEJIMA_NOTIFY_DIR on any of them either, not just on the
# ordinary feature-off success path the previous test covers.
stub_launch
survive_parent="$(mktemp -d)"
survive="$survive_parent/must-survive"
mkdir -p "$survive"
export DEJIMA_NOTIFY_DIR="$survive"
printf '[mounts]\nreadonly = %s\n' "$LAUNCH_DIR/does-not-exist" > "$CONFIG_DIR/config"
run launch "$PROJ"
unset DEJIMA_NOTIFY_DIR
[ "$status" -ne 0 ]
[ -d "$survive" ]
rm -rf "$survive_parent" "$PROJ"
}
# A fake `docker` reporting one running sandbox whose DEJIMA_RUN_ID env
# matches $1, so the liveness check can be exercised with no daemon.
stub_docker_run_id() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
case "\$1" in
ps) echo dejima-test-container ;;
inspect) echo "DEJIMA_RUN_ID=$1" ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
}
# $4 is the launch timestamp, defaulting to now. It matters to anything that
# prunes: dejima-attach holds off on an entry too young to have a container
# yet, so a test about pruning has to write one that is old enough.
# $5 is the launcher pid. Omitted by default, which writes the pre-pid entry
# shape - so every existing caller keeps exercising the upgrade fallback, and
# a test that means to exercise the liveness check says which pid it means.
write_entry() {
mkdir -p "$RUNTIME_DIR/dejima/sessions"
cat > "$RUNTIME_DIR/dejima/sessions/$1" <<EOF
workspace=/home/someone/myproject
pane=$2
label=$3
started=${4:-$(date +%s)}
EOF
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
}
# A pid that is certain to be dead: spawn a process and reap it, so the number
# is one the kernel has genuinely finished with rather than one picked out of
# the air and hoped to be free.
dead_pid() {
local p
( exit 0 ) &
p=$!
wait "$p" 2>/dev/null || true
printf '%s' "$p"
}
@test "dejima-attach refuses an id that is not a run id" {
write_remote_config
for bad in 'nope' '../../etc/passwd' '$(id)' '0123456789abcdef0'; do
run "$REPO_ROOT/bin/dejima-attach" "$bad"
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid session id"* ]]
done
}
@test "dejima-attach refuses a well-formed id with no registry entry" {
write_remote_config
run "$REPO_ROOT/bin/dejima-attach" 0123456789abcdef
[ "$status" -ne 0 ]
[[ "$output" == *"No such session"* ]]
}
@test "dejima-attach refuses to run at all when remote access is unconfigured" {
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
}
@test "--list prunes entries whose container is gone" {
write_remote_config
stub_docker_run_id ffffffffffffffff
# Old enough to be past the startup grace - a container that never existed
# and a container that has not been created yet look identical to `docker
# ps`, and only the age tells them apart.
write_entry 0123456789abcdef '%1' 'work:2' $(( $(date +%s) - 86400 ))
# Keyed by run id and written by the watcher, which never removes them:
# unless the prune takes them too, they outlive every session.
mkdir -p "$RUNTIME_DIR/dejima/debounce"
: > "$RUNTIME_DIR/dejima/debounce/0123456789abcdef-waiting"
: > "$RUNTIME_DIR/dejima/debounce/0123456789abcdef-done"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[ -z "$output" ]
# Pruned from disk, not merely hidden from the listing.
[ ! -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ]
[ -z "$(ls -A "$RUNTIME_DIR/dejima/debounce")" ]
}
@test "a session whose container has not started yet is not unregistered" {
# bin/dejima registers the session and then builds - an unconditional
# `docker compose build claude` plus up to two derived images - before it
# ever runs a container, so for that whole span `docker ps` answers "no
# such container" for a session that is perfectly alive. Pruning on that
# answer removes the entry for good (bin/dejima never rewrites it), which
# takes the session out of BOTH front doors, and deletes the marker
# directory that the imminent `docker compose run` bind-mounts.
write_remote_config
stub_docker_run_id ffffffffffffffff
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
[[ "$output" == *"starting"* ]]
[ -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ]
[ -d "$RUNTIME_DIR/dejima/notify/0123456789abcdef" ]
}
@test "--list marks a session started outside tmux as not attachable" {
write_remote_config
stub_docker_run_id 0123456789abcdef
write_entry 0123456789abcdef '' ''
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"myproject"* ]]
[[ "$output" == *"no-tmux"* ]]
}
@test "--list marks a session whose pane is gone as stale" {
write_remote_config
stub_docker_run_id 0123456789abcdef
write_entry 0123456789abcdef '%99999' 'work:2'
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"stale"* ]]
}
# A `docker` that reports one running container per registry entry, so a
# listing of N sessions can be built without N containers.
stub_docker_sessions() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
case "\$1" in
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
inspect) name="\${!#}"; echo "DEJIMA_RUN_ID=\${name#c-}" ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
}
@test "the picker reads the operator's choice in base 10, not base 8" {
# The range check is `[`, which is decimal-only, and the subscript is
# $(( )), which reads a leading zero as OCTAL. They therefore disagree
# about "010": in range as 10, indexed as 8, and the caller is attached to
# a session they did not pick with nothing printed to say so. A leading
# zero is not exotic on the front door this exists for - it is one stray
# keypress on a phone keyboard.
write_remote_config
stub_docker_sessions
local i
for i in $(seq 11); do write_entry "$(printf '%016d' "$i")" '' ''; done
# Every entry is pane-less, so attach() refuses by name - which is what
# makes the session that was actually picked visible at all.
run "$REPO_ROOT/bin/dejima-attach" <<< '010'
[ "$status" -ne 0 ]
[[ "$output" == *"Session 0000000000000010 was not started from tmux"* ]]
# "09" is not an octal number at all, so the same subscript aborts the
# script outright under set -e.
run "$REPO_ROOT/bin/dejima-attach" <<< '09'
[ "$status" -ne 0 ]
[[ "$output" == *"Session 0000000000000009 was not started from tmux"* ]]
# And a number `[` cannot compare: both comparisons exit 2, which makes
# the `if` false, so the range is never reported as the problem and the
# subscript dies on an unbound element instead.
run "$REPO_ROOT/bin/dejima-attach" <<< '99999999999999999999999'
[ "$status" -ne 0 ]
[[ "$output" == *"Out of range."* ]]
}
# A `docker` that can't answer - the daemon restarting, or docker not
# installed at all. Distinct from "no such container", and must stay that way.
stub_docker_broken() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock." >&2
exit 1
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
}
# A real tmux server of our own, on a private TMUX_TMPDIR so nothing here can
# touch an operator's sessions. Two windows, so selecting the right one is
# distinguishable from selecting the current one. $TARGET_PANE is in window 1.
start_tmux() {
TMUX_DIR="$(mktemp -d)"
export TMUX_TMPDIR="$TMUX_DIR"
unset TMUX
tmux -f /dev/null new-session -d -s work -x 80 -y 24 'sleep 300'
tmux new-window -t work -d 'sleep 300'
TARGET_PANE="$(tmux list-panes -t work:1 -F '#{pane_id}')"
}
@test "--list shows a live tmux pane as attachable" {
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"attachable"* ]]
}
# The picker is a human view; --list is a machine interface, and three things
# read it that way at once - the picker itself splits those rows on tabs to
# build its columns, ttyd feeds a run id from them back in through --url-arg,
# and the README documents them as scriptable. Colour in list_sessions would
# break all three, and no other test here can see it: bats never gives the
# script a terminal, which is the condition the colour is gated on. `script`
# supplies one.
@test "colour reaches the picker but never --list" {
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run script -qec "'$REPO_ROOT/bin/dejima-attach' --list" /dev/null
[ "$status" -eq 0 ]
[[ "$output" != *$'\033'* ]]
# Still tab-separated, so the shape the other readers rely on is intact
# rather than merely uncoloured.
[[ "$output" == *$'\t'"work:1"$'\t'* ]]
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *$'\033'* ]]
[[ "$output" == *"dejima"* ]]
# NO_COLOR is honoured on that same terminal, and the box-drawing glyphs
# ride the same switch - so the fallback is plain in both senses.
run script -qec "NO_COLOR=1 sh -c 'echo q | \"$REPO_ROOT/bin/dejima-attach\"'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" != *$'\033'* ]]
[[ "$output" == *"dejima | live sessions"* ]]
}
# Provenance, pinned offline. Upstream's color.table is the authority, but a
# test cannot go and fetch it, so the name->hex half is transcribed from it
# here. The half that catches a typo is checked rather than transcribed: every
# escape is decoded back to a hex and compared against the one written beside
# it, so a value edited in one place and not the other fails here instead of
# shipping under the name "gruvbox".
@test "the picker's palette is gruvbox, by upstream's own values" {
local -A gruvbox=(
[bright_orange]=fe8019 [light1]=ebdbb2 [gray_245]=928374
[dark4]=7c6f64 [dark2]=504945 [bright_green]=b8bb26
[bright_yellow]=fabd2f [bright_red]=fb4934 [bright_blue]=83a598
)
local seen=0 r g b name hex
while read -r r g b name hex; do
seen=$((seen + 1))
[ "${gruvbox[$name]:-unknown}" = "$hex" ]
[ "$(printf '%02x%02x%02x' "$r" "$g" "$b")" = "$hex" ]
done < <(sed -n \
"s/.*38;2;\([0-9]*\);\([0-9]*\);\([0-9]*\)m'.*# \([a-z_0-9]*\) *#\([0-9a-f]*\).*/\1 \2 \3 \4 \5/p" \
"$REPO_ROOT/bin/dejima-attach")
[ "$seen" -eq "${#gruvbox[@]}" ]
}
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
# The positive direction, which is where every interesting tmux mistake
# lives: an invalid set-option target, or setting destroy-unattached while
# the session is still detached, both leave the negative tests green while
# no one can actually attach.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
# A pty that is NOT a tmux pane: tmux refuses to nest, and ttyd/ssh give
# dejima-attach a bare pty too.
script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null >/dev/null 2>&1 &
attach_pid=$!
# Waits for the CLIENT, not just the session. dejima-attach creates the
# session and then attaches to it, so has-session goes true first and
# list-clients stays empty for a moment after - and the assertion below
# used to run inside that window. On an unloaded machine it lost the race
# rarely enough to look like noise; under load it failed 5 times in 12.
# Polling for the state being asserted on closes the window instead of
# widening it, and the assertions themselves are unchanged, so a genuine
# regression still fails here rather than timing out into a pass.
for _ in $(seq 100); do
[ "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" = "dejima-0123456789abcdef" ] && break
sleep 0.1
done
tmux has-session -t '=dejima-0123456789abcdef'
[ "$(tmux list-clients -F '#{client_session}')" = "dejima-0123456789abcdef" ]
# On the requested window - and the operator's own session was not dragged
# along to it.
[ "$(tmux display-message -p -t dejima-0123456789abcdef '#{window_index}')" = "1" ]
[ "$(tmux display-message -p -t work '#{window_index}')" = "0" ]
[ "$(tmux show-options -t dejima-0123456789abcdef destroy-unattached)" = "destroy-unattached on" ]
# destroy-unattached actually applied: the throwaway must not outlive the
# remote client, or every phone detach leaks a session on the host server.
kill "$attach_pid" 2>/dev/null || true
for _ in $(seq 50); do
if ! tmux has-session -t '=dejima-0123456789abcdef' 2>/dev/null; then break; fi
sleep 0.1
done
run tmux has-session -t '=dejima-0123456789abcdef'
[ "$status" -ne 0 ]
tmux has-session -t '=work'
}
@test "--list never prunes when docker cannot be asked" {
# "Couldn't ask" is not "it's gone". bin/dejima stamps the registry only at
# launch, so pruning here would permanently unregister every running
# sandbox - and rm -rf the notify dir out from under a live bind mount.
write_remote_config
stub_docker_broken
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[ -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ]
[ -d "$RUNTIME_DIR/dejima/notify/0123456789abcdef" ]
[[ "$output" == *"0123456789abcdef"* ]]
}
@test "a registry entry cannot execute anything through the uptime arithmetic" {
# started= reaches $(( )), where bash evaluates a non-numeric value as a
# variable name and runs any command substitution in its subscript - and
# set -u does not catch it when that name is already bound.
write_remote_config
stub_docker_run_id 0123456789abcdef
write_entry 0123456789abcdef '%1' 'work:2'
canary="$RUNTIME_DIR/PWNED"
printf 'started=now[$(touch %s)]\n' "$canary" >> "$RUNTIME_DIR/dejima/sessions/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ ! -e "$canary" ]
run "$REPO_ROOT/bin/dejima-attach" 0123456789abcdef
[ "$status" -ne 0 ]
[ ! -e "$canary" ]
}
@test "one unreadable entry does not hide every other live session" {
# The unreadable entry sorts first, so a fatal read aborts the loop before
# the live session below it is ever printed - and the picker's process
# substitution swallows the status, so sandboxes just silently vanish.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$1" in
ps) echo dejima-test-container ;;
# Reports both run IDs, so neither entry is pruned for the unrelated
# reason that its container is gone.
inspect) echo DEJIMA_RUN_ID=0000000000000000; echo DEJIMA_RUN_ID=0123456789abcdef ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
write_entry 0000000000000000 '%1' 'work:1'
write_entry 0123456789abcdef '%1' 'work:2'
chmod 000 "$RUNTIME_DIR/dejima/sessions/0000000000000000"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
}
@test "a TMUX_PANE containing a newline is refused" {
# $TMUX_PANE is taken straight from the launching shell's environment, the
# one value in the entry that is verbatim rather than derived. An injected
# line loses the parser's last-match-wins race to the real label= and
# started= that follow it, but wins outright for a second pane=.
stub_launch
write_remote_config
TMUX_PANE=$'%1\nstarted=now[$(id)]' run launch "$PROJ"
[ "$status" -ne 0 ]
[ -z "$(ls -A "$RUNTIME_DIR/dejima/sessions" 2>/dev/null)" ]
rm -rf "$PROJ"
}
@test "--list never prunes when docker inspect cannot answer for some container" {
# The other half of "couldn't ask": inspect failing for an unrelated
# container must not read as "this isn't the one" for the session being
# checked. Piping inspect into grep reports only grep's status, and a
# spurious prune is unrecoverable - bin/dejima stamps at launch only.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$1" in
ps) echo dejima-a; echo dejima-b ;;
inspect)
case "$*" in
*dejima-a*) echo DEJIMA_RUN_ID=aaaaaaaaaaaaaaaa ;;
*) echo "Error response from daemon: dejima-b" >&2; exit 1 ;;
esac
;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
write_entry aaaaaaaaaaaaaaaa '%1' 'work:1'
write_entry bbbbbbbbbbbbbbbb '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/bbbbbbbbbbbbbbbb"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"aaaaaaaaaaaaaaaa"* ]]
# B's container could not be examined, so B is unverified, not gone.
[ -f "$RUNTIME_DIR/dejima/sessions/bbbbbbbbbbbbbbbb" ]
[ -d "$RUNTIME_DIR/dejima/notify/bbbbbbbbbbbbbbbb" ]
}
@test "a marker directory the sandbox made undeletable does not kill the listing" {
# /run/dejima-notify is bind-mounted read-write, so a compromised session
# can chmod its own marker directory on the way out. Nothing in the prune
# tries to remove it, but a sandbox must not be able to deny the operator
# the front door by making a directory awkward, so this stays pinned.
write_remote_config
stub_docker_run_id 0123456789abcdef
write_entry 0000000000000000 '%1' 'work:1' 0999999999
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0000000000000000/waiting-dir"
chmod 500 "$RUNTIME_DIR/dejima/notify/0000000000000000"
run "$REPO_ROOT/bin/dejima-attach" --list
chmod 700 "$RUNTIME_DIR/dejima/notify/0000000000000000"
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
}
# The marker directory is the bind source of a mount bin/dejima declares, and
# its lifetime belongs to that process's EXIT trap. A second owner in the
# prune can only race it, and losing that race does not leave a tidy host: it
# leaves Docker recreating the bind source root-owned, dejima-notify.sh
# failing on every Notification and Stop for the rest of the run, and no code
# anywhere that rewrites the registry entry to make the session visible again.
@test "pruning a dead entry leaves the marker directory alone" {
write_remote_config
stub_docker_run_id ffffffffffffffff
write_entry 0123456789abcdef '%1' 'work:2' 0999999999 "$(dead_pid)"
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[ ! -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ] \
|| { echo "the registry entry survived a dead launcher"; return 1; }
[ -d "$RUNTIME_DIR/dejima/notify/0123456789abcdef" ] \
|| { echo "the prune deleted a marker directory it does not own"; return 1; }
}
# The failure this replaced: a cold first build outran STARTUP_GRACE, the
# operator opened the front door to check on it, and the session was
# unregistered mid-launch. A build takes as long as it takes; whether the
# process that registered the entry is still running does not.
@test "a launch still building is not pruned however long it has taken" {
write_remote_config
stub_docker_run_id ffffffffffffffff
# started= far enough back that any guessed window has long since passed.
write_entry 0123456789abcdef '%1' 'work:2' 0999999999 $$
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
[[ "$output" == *"starting"* ]]
[ -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ]
[ -d "$RUNTIME_DIR/dejima/notify/0123456789abcdef" ]
}
@test "an entry whose pid is not a plain number is refused, not signalled" {
# pid reaches `kill`, which accepts a job spec and a negative pgid as well
# as a process. Neither may be reachable from a state file.
write_remote_config
stub_docker_run_id ffffffffffffffff
for bad in '%1' '-1' 'x[$(id)]' ' 1'; do
write_entry 0123456789abcdef '%1' 'work:2' 0999999999 "$bad"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ] || { echo "pid '$bad' broke the listing: $output"; return 1; }
[ ! -f "$RUNTIME_DIR/dejima/sessions/0123456789abcdef" ] \
|| { echo "pid '$bad' was treated as a live launcher"; return 1; }
done
}
# Waits for the grouped session an attach should have created, then prints
# "<name> <group>" for it - empty group means UNGROUPED, i.e. a fresh session
# tmux conjured out of a name rather than a clone of the sandbox's own.
#
# Waits for the CLIENT rather than the session, like the earlier attach test
# does. dejima-attach creates the session detached, checks it holds the pane,
# selects the window and only then attaches - so has-session goes true while
# the session is still on whatever window the group started on. A caller that
# goes on to assert the window index reads it inside that gap. That is how
# this failed in CI, with the group assertion on the line before it passing:
# the group is set at creation, the window is not.
await_group() {
local i
for i in $(seq 100); do
[ "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" = "dejima-$1" ] && break
sleep 0.1
done
tmux list-sessions -F '#{session_name} #{session_group}' | grep "^dejima-$1 " || true
}
@test "a recorded session name that no longer exists cannot conjure a session of its own" {
# tmux does not fail on `new-session -t <nothing-matches>` - it creates a
# fresh ungrouped session under that name, which for a remote client is a
# plain host shell with no sandbox in it. The base session must therefore
# come from the live pane, never from the recorded label.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'nope:0'
script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null >/dev/null 2>&1 &
attach_pid=$!
# Grouped with the session that actually holds the pane, on its window.
[ "$(await_group 0123456789abcdef)" = "dejima-0123456789abcdef work" ]
[ "$(tmux display-message -p -t dejima-0123456789abcdef '#{window_index}')" = "1" ]
kill "$attach_pid" 2>/dev/null || true
}
@test "attaching follows the pane's session across a rename, not the recorded name" {
# The registry can outlive the shape of the operator's tmux server. If the
# base came from the label, renaming the real session and letting anything
# else take the old name would silently attach the remote client to that
# unrelated session.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
tmux rename-session -t '=work' old
tmux new-session -d -s work 'sleep 300'
script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null >/dev/null 2>&1 &
attach_pid=$!
[ "$(await_group 0123456789abcdef)" = "dejima-0123456789abcdef old" ]
[ "$(tmux display-message -p -t dejima-0123456789abcdef '#{window_index}')" = "1" ]
kill "$attach_pid" 2>/dev/null || true
}
@test "--list survives a timestamp with a leading zero" {
# Bash reads a leading zero as octal, so an all-digits "0999999999" is
# still not a number and $(( )) would abort the whole listing under set -e.
# The 10# that fixes it is also the second line of defence behind the
# started validation: "10#now[$(cmd)]" fails to lex before the array
# subscript is ever evaluated.
write_remote_config
stub_docker_run_id 0123456789abcdef
write_entry 0123456789abcdef '%1' 'work:2'
sed -i 's/^started=.*/started=0999999999/' "$RUNTIME_DIR/dejima/sessions/0123456789abcdef"
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
}
# A tmux that reproduces the one behavior a live race produces and no test can
# otherwise force: display-message printing nothing, and still exiting 0, for
# a pane that went away after the liveness check. Everything else passes
# through to the real tmux.
stub_tmux_lost_pane() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
cat > "$STUB_DIR/tmux" <<EOF
#!/bin/bash
for arg in "\$@"; do
[ "\$arg" = '#{session_name}:#{window_index}' ] && exit 0
done
exec $(command -v tmux) "\$@"
EOF
chmod +x "$STUB_DIR/tmux"
PATH="$STUB_DIR:$PATH"
}
@test "a pane that vanishes between the liveness check and the attach is refused" {
# Without the guard, an empty session name and window index are handed
# straight to tmux, which reads them as "no target given" - a new ungrouped
# session, and the group's current window rather than the sandbox's.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
stub_tmux_lost_pane
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" 0123456789abcdef
[ "$status" -ne 0 ]
[[ "$output" == *"stale"* ]]
run tmux has-session -t '=dejima-0123456789abcdef'
[ "$status" -ne 0 ]
}
# A tmux that reports the sandbox pane as living in a session it destroys on
# the way out. Not hypothetical: display-message names whichever session
# holding the window is most recent, which is typically another remote
# client's grouped throwaway - and those carry destroy-unattached, so that
# client detaching is what makes the name stop resolving.
stub_tmux_doomed_base() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
cat > "$STUB_DIR/tmux" <<EOF
#!/bin/bash
real=$(command -v tmux)
for arg in "\$@"; do
if [ "\$arg" = '#{session_name}:#{window_index}' ]; then
"\$real" kill-session -t '=doomed' 2>/dev/null
echo 'doomed:1'
exit 0
fi
done
exec "\$real" "\$@"
EOF
chmod +x "$STUB_DIR/tmux"
PATH="$STUB_DIR:$PATH"
}
@test "a base session that disappears before the attach yields no session at all" {
# tmux does not fail on `new-session -t <matches nothing>` - it silently
# creates a fresh UNGROUPED session, i.e. a plain host shell with no
# sandbox in it, which is the containment escape. Since the base is
# routinely another client's throwaway, whoever holds a second connection
# can drive this race, so the created session must be verified to actually
# hold the sandbox pane rather than assumed to.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
# Stands in for that other client's group: really does hold the pane, and
# really does go away.
tmux new-session -d -t '=work' -s doomed
stub_tmux_doomed_base
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run timeout 20 script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null
[ "$status" -ne 0 ]
[[ "$output" == *"stale"* ]]
# Nothing left behind for a client to be sitting in.
run tmux list-sessions -F '#{session_name}'
[[ "$output" != *"dejima-0123456789abcdef"* ]]
}
@test "an attach that cannot open a terminal leaves no session behind" {
# `restrict,pty` in an authorized_keys entry permits a pty, it doesn't
# force one, so `ssh -T <host>` runs the forced command with no terminal -
# and the group is created before tmux ever tries to open one. A leaked
# group never gets destroy-unattached, and every later attach to that run
# id joins it instead of creating one, so it stays leaked.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" 0123456789abcdef
[ "$status" -ne 0 ]
run tmux has-session -t '=dejima-0123456789abcdef'
[ "$status" -ne 0 ]
tmux has-session -t '=work'
}
@test "attaching to a group that already exists still sets destroy-unattached" {
# The option is per-session state, not per-client, so a group that ever
# came into being without it outlives its client on every later attach.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
tmux new-session -d -t '=work' -s 'dejima-0123456789abcdef'
# show-options prints nothing at all for an option left at its default.
[ -z "$(tmux show-options -t dejima-0123456789abcdef destroy-unattached)" ]
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null >/dev/null 2>&1 &
attach_pid=$!
for _ in $(seq 50); do
[ -n "$(tmux list-clients -F '#{client_session}')" ] && break
sleep 0.1
done
[ "$(tmux list-clients -F '#{client_session}')" = "dejima-0123456789abcdef" ]
[ "$(tmux show-options -t dejima-0123456789abcdef destroy-unattached)" = "destroy-unattached on" ]
kill "$attach_pid" 2>/dev/null || true
}
@test "an existing group that no longer holds the sandbox's pane is not attached to" {
# The has-session branch hands the client whatever carries the name, and
# nothing in this script created it. Only reachable as debris - a
# dejima-<run-id> that exists but shares no window with the sandbox - and
# anyone who can create that name already has a host shell, so the stake
# is landing in the right window rather than containment. The session
# created twenty lines below is verified for exactly this; this one was
# not.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
tmux new-session -d -s 'dejima-0123456789abcdef' 'sleep 300'
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" 0123456789abcdef
[ "$status" -ne 0 ]
# The message, not just the failure: with no pty this attach cannot
# succeed either way, and "open terminal failed" would pass a bare status
# check while still having tried to hand over the wrong session.
[[ "$output" == *"does not hold its pane"* ]]
[ -z "$(tmux list-clients -F '#{client_session}')" ]
}
@test "an existing session whose name merely starts with the group name is not attached to" {
# tmux resolves an unanchored target by prefix, then by fnmatch, so a
# session named dejima-<id><anything> answers to the group's own name -
# and "is the group already there?" answering yes about someone else's
# session hands the remote client that session instead of the sandbox.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
tmux new-session -d -s 'dejima-0123456789abcdefx' 'sleep 300'
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
script -qec "$REPO_ROOT/bin/dejima-attach 0123456789abcdef" /dev/null >/dev/null 2>&1 &
attach_pid=$!
for _ in $(seq 50); do
[ -n "$(tmux list-clients -F '#{client_session}')" ] && break
sleep 0.1
done
[ "$(tmux list-clients -F '#{client_session}')" = "dejima-0123456789abcdef" ]
# And it is the sandbox's window, not the decoy's shell.
tmux list-panes -s -t '=dejima-0123456789abcdef' -F '#{pane_id}' | grep -qxF "$TARGET_PANE"
kill "$attach_pid" 2>/dev/null || true
}
@test "the notify hook writes an empty file named for the event and nothing else" {
NOTIFY="$(mktemp -d)"
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
[ -f "$NOTIFY/waiting" ]
[ ! -s "$NOTIFY/waiting" ]
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
[ "$status" -eq 0 ]
[ -f "$NOTIFY/done" ]
[ ! -s "$NOTIFY/done" ]
# The marker's name is its entire meaning: the two events it accepts
# created two files and nothing else. (Stdin is a separate property, and
# this test does not pin it: `run` does not redirect stdin, so what the
# hook gets here is whatever the suite was invoked with - normally
# /dev/null, and normally already drained by an earlier test. See the
# test below, which supplies stdin itself rather than inheriting it.)
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "done waiting " ]
rm -rf "$NOTIFY"
}
@test "the notify hook never reads its stdin" {
# Stdin is where sandbox-controlled bytes actually arrive: Claude Code
# hands a hook a JSON payload containing the Notification `message` the
# session itself wrote, plus transcript_path and cwd. If any of it landed
# in the marker, the watcher would be composing the operator's phone
# notification partly from text the sandbox chose.
#
# This has to supply stdin itself. The other hook tests run under bats'
# `run`, which is a plain command substitution with no stdin redirection,
# so the hook there inherits whatever the suite was invoked with: at
# /dev/null (the usual case) a hook reading stdin into the marker leaves
# it empty and passes. Those tests are not stdin-independent, they are
# stdin-ambient - which is exactly why this property needs its own.
NOTIFY="$(mktemp -d)"
payload='{"message":"INJECTED-DEEP-LINK-https://evil.invalid/x","cwd":"/w"}'
payload_file="$(mktemp)"
printf '%s' "$payload" > "$payload_file"
# A regular file behind a shared fd, not a pipe: a pipe could not support
# this check at all. The hook inherits fd 9's open file description,
# offset included, so the assertion is the stronger "the bytes were never
# consumed" rather than "the bytes did not reach the marker" - anything
# the hook read, even to discard it, would be missing from what is left
# to read here afterwards.
exec 9<"$payload_file"
DEJIMA_NOTIFY_DIR="$NOTIFY" "$REPO_ROOT/hooks/dejima-notify.sh" waiting <&9
leftover="$(cat <&9)"
exec 9<&-
[ "$leftover" = "$payload" ]
[ -f "$NOTIFY/waiting" ]
[ ! -s "$NOTIFY/waiting" ]
rm -rf "$NOTIFY" "$payload_file"
}
@test "the notify hook refuses any event name outside the enum" {
NOTIFY="$(mktemp -d)"
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" "$bad"
[ "$status" -eq 0 ]
done
# Nothing at all was created - the enum is a whitelist, not a filter.
[ -z "$(ls -A "$NOTIFY")" ]
rm -rf "$NOTIFY"
}
@test "the notify hook is inert when the marker directory is not mounted" {
# Outside a remote-enabled session the variable is simply absent, and a
# hook that failed there would surface an error on every notification in
# an ordinary sandbox. A stale value pointing at nothing is the same
# case, so it is refused the same way rather than by creating the path.
run env -u DEJIMA_NOTIFY_DIR "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
missing="$(mktemp -d)/gone"
DEJIMA_NOTIFY_DIR="$missing" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
[ ! -e "$missing" ]
}
@test "notify hooks are registered only in the throwaway override layer" {
stub_launch
write_remote_config
launch "$PROJ"
grep -q 'dejima-notify.sh waiting' "$LAUNCH_DIR/settings-copy.json"
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
# The layer is a throwaway, so the operator's real settings.json - which
# nothing registers this hook into - is left byte-for-byte alone.
cmp -s "$LAUNCH_DIR/settings-seed.json" "$LAUNCH_DIR/home/.claude/settings.json"
# And what the layer carried before is still there: this appends, it does
# not replace the file bin/dejima generated.
[ "$(jq '.sandbox.enabled' "$LAUNCH_DIR/settings-copy.json")" = "false" ]
# The operator's OWN Notification and Stop hooks - a desktop notifier, a
# TTS reader - are deliberately NOT copied in here. They keep applying
# from their own settings.json, which Claude Code loads as the lower
# layer and merges hooks from rather than letting this one replace it.
# Copying them would run each of them twice. Verified against Claude Code
# 2.1.220: a SessionStart hook in a --settings file and one in
# ~/.claude/settings.json both fire.
[ "$(jq '.hooks.Notification | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
rm -rf "$PROJ"
}
# A fake `curl` that records the ARGUMENT VECTOR it was handed: joined, and
# one argument per line. Deliberately not "what would have been sent" - real
# curl expands @file (in -H and -d), -K and -T, so bytes can reach the wire
# without ever appearing in argv. Confirmed against curl 7.88.1 and a live
# listener: `-H @/path` arrives as the header the file contains. Assertions
# that a secret is ABSENT from this log therefore prove nothing on their own;
# the whitelist below is what makes the log sufficient.
stub_curl() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
cat > "$STUB_DIR/curl" <<EOF
#!/bin/bash
printf '%s\n' "\$@" >> "$STUB_DIR/curl-argv"
exit 0
EOF
chmod +x "$STUB_DIR/curl"
PATH="$STUB_DIR:$PATH"
}
curl_log() { cat "$STUB_DIR/curl-argv" 2>/dev/null; }
# The EXACT argument vector of one push, one argument per line - a whitelist,
# not a search. Absence assertions cannot do this job: an argument that leaks
# by REFERENCE ("-H @/path/to/secret") contains none of the bytes it sends, so
# every grep for the secret passes while the secret goes out. Compared whole,
# an added argument of any kind fails the test.
#
# $1 title suffix, $2 body, $3 click URL.
expect_argv() {
printf '%s\n' -fsS --max-time 10 --retry 2 \
-H "Title: dejima: $1" \
-H "Priority: default"
[ -z "${3:-}" ] || printf '%s\n' -H "Click: $3"
printf '%s\n' -d "$2" -- https://ntfy.example.invalid/dejima
}
# Compared as a whole file, so a SECOND push would fail this too. The
# sentinel is what makes "whole" true: both sides are command substitutions,
# which strip trailing newlines, so without it a vector ending in an empty
# argument compares equal to one that does not have it.
assert_argv() {
[ "$( { cat "$STUB_DIR/curl-argv"; echo .; } )" = "$( { expect_argv "$@"; echo .; } )" ]
}
marker() {
mkdir -p "$RUNTIME_DIR/dejima/notify/$1"
: > "$RUNTIME_DIR/dejima/notify/$1/$2"
}
conf_with_ntfy() {
cat > "$CONFIG_DIR/config" <<'EOF'
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/dejima
base_url = https://dejima.example.invalid
EOF
}
@test "a marker produces a push composed entirely from host-side data" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# The click URL is base_url from the config plus the DIRECTORY NAME, and
# the project name comes from the registry's workspace= (host-written by
# bin/dejima; write_entry sets it to /home/someone/myproject). Nothing
# here is sourced from the marker.
# Every argument, in order, and nothing else. The URL reaches curl as the
# URL - last, after --, verbatim - and not as an option.
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
# Consumed, so a rerun doesn't re-push.
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
# The other half of the enum, which is otherwise never exercised. The log
# is compared whole, so it starts empty for the second push.
: > "$STUB_DIR/curl-argv"
marker 0123456789abcdef done
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
assert_argv 'myproject finished' 'Session has stopped.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/done" ]
}
@test "a marker name outside the enum is never acted on" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
dir="$RUNTIME_DIR/dejima/notify/0123456789abcdef"
mkdir -p "$dir"
# Content is never read, so these are only ever names - and none of them
# is in the enum. The hook's own enum constrains nothing here: a session
# owns the contents of its bind-mounted marker directory and can create
# any name it likes in it, hook or no hook.
for name in error 'waiting; curl evil' 'X-Actions' 'waiting.' WAITING done2; do
printf 'PAYLOAD' > "$dir/$name"
done
before="$(ls -A "$dir" | sort)"
[ -n "$before" ]
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ -z "$(curl_log)" ]
# Not merely un-pushed: untouched. An unrecognized name is never stat'd,
# read, or removed, so the whitelist cannot quietly become a filter.
[ "$(ls -A "$dir" | sort)" = "$before" ]
}
@test "marker content never reaches the push" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
# ntfy's X-Actions header makes the phone issue an arbitrary HTTP request
# or fire an Android intent - one attacker-chosen header is a confused
# deputy inside the operator's LAN, which is why content is never read.
printf 'X-Actions: http, pwn, https://evil.example.invalid\n' \
> "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# The whole vector, not a grep for the payload: a watcher that passed the
# marker on by reference (-H @"$marker", or a copy of it) would send this
# header verbatim while every absence assertion still passed.
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "a symlinked marker is removed without its target being read or touched" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
secret="$RUNTIME_DIR/host-secret"
printf 'HOSTSECRET' > "$secret"
ln -s "$secret" "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# The link is gone, the host file it aimed at is intact, and nothing from
# it was sent. rm unlinks a symlink rather than following it; a watcher
# that opened the marker instead would be reading an operator-chosen file.
[ ! -L "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
[ -f "$secret" ]
[ "$(cat "$secret")" = "HOSTSECRET" ]
# Intact is not enough - the target must not have been READ either, and a
# read that leaves the file alone is invisible from the filesystem. The
# whole vector says so: no argument names the marker, the link, or the
# target, so there is nothing for curl to open on their behalf.
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "a dangling symlinked marker is consumed rather than left in place forever" {
# [ -e ] follows the link and is FALSE for a dangling one, so without the
# -L arm a session could leave links a scan never clears - and the target
# must still not be created on its behalf.
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef"
ln -s "$RUNTIME_DIR/nothing-here" "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ ! -L "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
[ ! -e "$RUNTIME_DIR/nothing-here" ]
[ -n "$(curl_log)" ]
}
@test "a marker directory whose name is not a run id is ignored entirely" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
# Beside the marker root, which is where a "../"-shaped name would land if
# one were ever joined onto the scan path.
mkdir -p "$RUNTIME_DIR/dejima/escape-attempt"
: > "$RUNTIME_DIR/dejima/escape-attempt/waiting"
for bad in NOTARUNID 0123456789ABCDEF 0123456789abcde 0123456789abcdef0; do
mkdir -p "$RUNTIME_DIR/dejima/notify/$bad"
: > "$RUNTIME_DIR/dejima/notify/$bad/waiting"
done
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ -z "$(curl_log)" ]
# And none of them was consumed either.
[ -f "$RUNTIME_DIR/dejima/notify/NOTARUNID/waiting" ]
[ -f "$RUNTIME_DIR/dejima/escape-attempt/waiting" ]
}
@test "repeat markers for the same event are debounced" {
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
"$REPO_ROOT/bin/dejima-notify-watch" --once
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# Still that one push and no other: the vector is compared whole, so a
# second would appear as trailing arguments and fail here.
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
# Debounced means "not pushed", not "not consumed" - a marker left behind
# would re-push the moment the window expired.
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
}
@test "the watcher refuses to start without ntfy_url" {
write_remote_config
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"ntfy_url"* ]]
}
@test "the watcher refuses to start at all when remote access is unconfigured" {
# The opt-in guarantee: with the feature unconfigured nothing here runs,
# and in particular no state directory is created.
stub_curl
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
[ ! -d "$RUNTIME_DIR/dejima" ]
[ -z "$(curl_log)" ]
}
@test "a config value curl would read as an option is refused, not passed on" {
# The url schema type requires an http(s) scheme, which "-K/path" is not -
# verified against the real curl, which would otherwise read "-K/path" as
# an OPTION and take the request's URL (and its output file) from that
# path instead. A value that isn't a URL is refused at parse time, before
# anything is pushed. The config is operator-written, which is a reason
# to refuse a bad value loudly rather than to assume a good one.
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
write_remote_config 'ntfy_url = -K/tmp/dejima-test-curl-config'
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"ntfy_url"* ]]
[ -z "$(curl_log)" ]
# base_url reaches the phone as the Click: target rather than curl's argv,
# but it is the same class of value and the same refusal.
write_remote_config 'ntfy_url = https://ntfy.example.invalid/t' \
'base_url = -o/tmp/dejima-test-out'
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"base_url"* ]]
[ -z "$(curl_log)" ]
# Refused before anything was consumed, so a real marker is still there.
[ -f "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
}
@test "the debounce store cannot execute anything through the age arithmetic" {
# Same shape as the registry's started=: bash evaluates a non-numeric
# value in $(( )) as a VARIABLE NAME and runs any command substitution in
# its subscript, and set -u does not catch it because `now` is bound right
# there. The debounce directory is host-owned and never mounted, so this
# is about not leaving the pattern in the repo - it shipped as a Critical
# once already, in bin/dejima-attach.
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
mkdir -p "$RUNTIME_DIR/dejima/debounce"
canary="$RUNTIME_DIR/PWNED"
printf 'now[$(touch %s)]' "$canary" \
> "$RUNTIME_DIR/dejima/debounce/0123456789abcdef-waiting"
# Bounded because this is the probe most likely to reach an arithmetic
# error, and such an error unwinds to the top level, discarding the rest
# of the enclosing command - the script keeps its --once exit OUT of that
# command for exactly this reason, and if that ever regresses a bounded
# test fails where an unbounded one hangs the whole file.
run timeout 30 "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ ! -e "$canary" ]
# An unparseable stamp reads as "not sent recently": failing the other way
# would let one junk file silence a session's notifications for good.
[ -n "$(curl_log)" ]
}
@test "a marker the watcher cannot consume does not silence every other session" {
# /run/dejima-notify is bind-mounted read-write, so a session can make its
# own marker undeletable - a directory named `waiting` is enough. One scan
# covers every run directory, so an rm that is fatal under set -e lets one
# compromised sandbox suppress all the others' notifications. The
# undeletable one sorts first, so it is reached first.
conf_with_ntfy
stub_curl
write_entry 0000000000000000 '%1' 'work:1'
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0000000000000000/waiting/not-empty"
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[[ "$(curl_log)" == *"?arg=0123456789abcdef"* ]]
[[ "$output" == *"warning"* ]]
# And the one it could not consume was not pushed either: a marker that
# survives would otherwise re-push on every single scan.
[[ "$(curl_log)" != *"?arg=0000000000000000"* ]]
}
@test "an unreadable registry entry does not abort the scan" {
# project_of reads the registry with `done < "$file"`, which is fatal
# under set -e when the file cannot be opened - and again, one scan covers
# every run directory.
conf_with_ntfy
stub_curl
write_entry 0000000000000000 '%1' 'work:1'
write_entry 0123456789abcdef '%1' 'work:2'
chmod 000 "$RUNTIME_DIR/dejima/sessions/0000000000000000"
marker 0000000000000000 waiting
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[[ "$(curl_log)" == *"?arg=0123456789abcdef"* ]]
# The entry it could not read still gets a push, just without a name.
[[ "$(curl_log)" == *"?arg=0000000000000000"* ]]
}
@test "the watcher refuses a state directory that is a symlink" {
# Same hazard bin/dejima already refuses: /tmp is the fallback parent when
# XDG_RUNTIME_DIR is unset, and `mkdir -p -m 700` only sets the mode of
# the leaf it creates, so a planted symlink here would send every mkdir
# and every rm below it into someone else's tree.
conf_with_ntfy
stub_curl
elsewhere="$(mktemp -d)"
mkdir -p "$elsewhere/notify/0123456789abcdef"
: > "$elsewhere/notify/0123456789abcdef/waiting"
ln -s "$elsewhere" "$RUNTIME_DIR/dejima"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]]
[ -z "$(curl_log)" ]
[ -f "$elsewhere/notify/0123456789abcdef/waiting" ]
rm -rf "$elsewhere"
}
@test "a push still goes out when base_url is not configured" {
# base_url is optional - ntfy alone is a complete setup, and it is the
# only half that needs to be reachable from the phone. This pins the
# otherwise-unexercised branch that composes no Click header at all,
# including the empty-array expansion it hands to curl under set -u.
printf '[remote-access]\nenabled = on\nntfy_url = https://ntfy.example.invalid/dejima\n' \
> "$CONFIG_DIR/config"
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# No Click argument at all, rather than an empty one.
assert_argv 'myproject needs input' 'Session is waiting for you.' ''
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
}
@test "an argument that is not --once is refused rather than watched forever" {
# Anything unrecognized - a typo, --help - used to fall into the watch
# loop, which for an interactive invocation looks like a hang.
conf_with_ntfy
stub_curl
for bad in --help --onces -x once; do
run timeout 10 "$REPO_ROOT/bin/dejima-notify-watch" "$bad"
[ "$status" -eq 2 ]
[[ "$output" == *"usage:"* ]]
done
[ -z "$(curl_log)" ]
}
@test "the watcher refuses a state directory it does not own or that is not a directory" {
# The other two arms of the same guard. The ownership arm is the one that
# matters for the /tmp fallback - another local user pre-creating
# /tmp/dejima as a real directory of their own - and it cannot be set up
# for real without root, so the ownership PROBE is stubbed instead: the
# script asks `stat -c %u`, and here it gets an answer that is not us.
conf_with_ntfy
stub_curl
cat > "$STUB_DIR/stat" <<EOF
#!/bin/bash
[ "\$1" = "-c" ] && [ "\$2" = "%u" ] && { printf '4242\n'; exit 0; }
exec $(command -v stat) "\$@"
EOF
chmod +x "$STUB_DIR/stat"
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"owned"* ]]
[ -z "$(curl_log)" ]
# Refused, not cleaned up: it is not ours to touch.
[ -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
# And the arm for a state path that is not a directory at all.
rm -f "$STUB_DIR/stat"
rm -rf "$RUNTIME_DIR/dejima"
printf 'not a directory' > "$RUNTIME_DIR/dejima"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[ -z "$(curl_log)" ]
[ "$(cat "$RUNTIME_DIR/dejima")" = "not a directory" ]
}
@test "a workspace path that would split a header is reduced before it is sent" {
# The registry is host-written, so this is not a sandbox escape - it is an
# operator whose directory name contains a CR, which would end the Title
# header and start one of the reader's choosing.
conf_with_ntfy
stub_curl
mkdir -p "$RUNTIME_DIR/dejima/sessions"
# No slash in the payload: basename would eat everything before the last
# one, which would hide what this test is about.
printf 'workspace=/home/someone/my%sX-Actions: pwn\npane=%%1\n' \
"$(printf '\r')" > "$RUNTIME_DIR/dejima/sessions/0123456789abcdef"
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
# One Title argument, on one line, with the CR, the space and the colon
# gone - so there is no second header and no header value to control.
assert_argv 'myX-Actionspwn needs input' \
'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "a workspace name longer than the header allowance is truncated" {
conf_with_ntfy
stub_curl
mkdir -p "$RUNTIME_DIR/dejima/sessions"
long="$(printf 'a%.0s' $(seq 1 200))"
printf 'workspace=/home/someone/%s\npane=%%1\n' "$long" \
> "$RUNTIME_DIR/dejima/sessions/0123456789abcdef"
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
assert_argv "$(printf 'a%.0s' $(seq 1 64)) needs input" \
'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "a debounce window that is not a number is refused" {
# debounce used to reach an arithmetic context straight from
# DEJIMA_DEBOUNCE_SECONDS; it now goes through the schema's int:0:86400
# at config parse time instead, before this script ever sees it.
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
for bad in 'x[$(touch '"$RUNTIME_DIR"'/PWNED)]' '60s' '-1' '6 0'; do
write_remote_config 'ntfy_url = https://ntfy.example.invalid/t' "debounce = $bad"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"invalid value"* ]]
[[ "$output" == *"debounce"* ]]
done
[ ! -e "$RUNTIME_DIR/PWNED" ]
[ -z "$(curl_log)" ]
# Refused before the scan, so the marker is still there to push later.
[ -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
}
@test "an inotifywait that returns immediately does not spin the watcher" {
# Both ways a sandbox can make the wait stop waiting, and they need
# different guards:
# exit 1 - the wait FAILS. Reachable from inside: -r takes one inotify
# watch per directory under the marker root, and a session owns its
# own marker directory, so it can mkdir enough of them to exhaust this
# uid's max_user_watches. Bounded by the fallback sleep.
# exit 0 - the wait SUCCEEDS, over and over. Needs no exhaustion at all:
# the enum does not gate the wake path, so any file the session
# creates is an event. Bounded by the floor under the loop.
conf_with_ntfy
stub_curl
for rc in 1 0; do
# Thresholds differ because the two guards bound different rates, and
# a threshold the floor alone can meet would leave the other unpinned:
# rc=1 - floor then a five second fallback, so one call in three
# seconds. Two or more means only the floor is left.
# rc=0 - the floor alone, about one a second. Unbounded, this is in
# the hundreds (571 in five seconds, measured).
case "$rc" in
1) max=2 ;;
0) max=10 ;;
esac
: > "$STUB_DIR/inotify-calls"
cat > "$STUB_DIR/inotifywait" <<EOF
#!/bin/bash
printf 'x' >> "$STUB_DIR/inotify-calls"
exit $rc
EOF
chmod +x "$STUB_DIR/inotifywait"
"$REPO_ROOT/bin/dejima-notify-watch" >/dev/null 2>&1 &
watcher=$!
sleep 3
kill "$watcher" 2>/dev/null || true
wait "$watcher" 2>/dev/null || true
[ "$(wc -c < "$STUB_DIR/inotify-calls")" -lt "$max" ]
done
}
@test "a marker that cannot be consumed is warned about once, not once per scan" {
# The warning is on the path a session controls - it makes its own marker
# undeletable - and the loop revisits it on every scan, so undeduplicated
# this is the journal-growth half of the same denial of service.
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
mkdir -p "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting/not-empty"
# Scans have to actually repeat for this to mean anything, and this host
# has no inotifywait, which would leave the loop sleeping 5s and the test
# vacuous. A stub that returns at once puts it at the floor: ~3 scans.
cat > "$STUB_DIR/inotifywait" <<'EOF'
#!/bin/bash
exit 0
EOF
chmod +x "$STUB_DIR/inotifywait"
"$REPO_ROOT/bin/dejima-notify-watch" > /dev/null 2> "$STUB_DIR/watch-err" &
watcher=$!
sleep 3
kill "$watcher" 2>/dev/null || true
wait "$watcher" 2>/dev/null || true
[ "$(grep -c 'could not consume marker' "$STUB_DIR/watch-err")" -eq 1 ]
}
@test "the state directory is tightened even when another process created it" {
# -m 700 applies only to the process that creates the directory, and by
# this script's own design either side may get there first.
conf_with_ntfy
stub_curl
mkdir -p -m 755 "$RUNTIME_DIR/dejima"
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ "$(stat -c %a "$RUNTIME_DIR/dejima")" = "700" ]
}
# ---------------------------------------------------------------------------
# The front doors: bin/dejima-ttyd, the systemd units that run it and the
# watcher, the nginx example that fronts it, and the install.sh wiring that
# puts all of it where those units expect to find it.
#
# Most of what follows is necessarily static - there is no ttyd, no systemd
# and no nginx on the machines this suite runs on. Two things guard against
# the vacuous pass that invites: every negative grep is preceded by a check
# that its files exist (a grep against a missing file "finds nothing" too),
# and the two properties that actually matter - what ttyd is launched with,
# and what environment the units give the scripts - are executed rather than
# grepped.
# ---------------------------------------------------------------------------
# A fake ttyd that records the argv it was exec'd with. ttyd is not installed
# here, which is exactly why it is stubbed rather than skipped: without it
# every "dejima-ttyd fails" assertion below is true for the wrong reason.
stub_ttyd() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
cat > "$STUB_DIR/ttyd" <<EOF
#!/bin/bash
printf '%s\n' "\$@" >> "$STUB_DIR/ttyd-argv"
exit 0
EOF
chmod +x "$STUB_DIR/ttyd"
PATH="$STUB_DIR:$PATH"
}
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
# Reports no listener, whatever the host is actually running. Opt-in, and
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
# below, which binds a real socket and needs the real ss to see it.
#
# For every OTHER ttyd test the port being free is a precondition, not the
# subject - and one that was being inherited from the machine rather than
# stated. The default-port case asserts against 7681, which is exactly the
# port a real dejima-ttyd front door holds, so it failed on any host running
# the feature: the machine a developer is most likely to run this suite on.
# Same shape as #56 - a test reading its environment instead of setting it.
stub_ss_no_listeners() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/ss"
chmod +x "$STUB_DIR/ss"
PATH="$STUB_DIR:$PATH"
}
# The EXACT argument vector, one argument per line - a whitelist, for the same
# reason expect_argv above is one. The bind address is the security property
# of this whole task, and an absence grep cannot see a SECOND --interface, a
# --credential that turns nginx's auth into a duplicate, or a dropped flag.
# $1 is the expected port. The command must be this repo's dejima-attach,
# resolved the way the script resolves it (readlink -f, so a $BATS_TEST_DIRNAME
# with a ".." in it does not make this a comparison of two spellings).
# $2 is the expected TTYD_FONT_SIZE, empty or absent for "no -t at all" - the
# unset default, which has to stay a genuine absence rather than a value that
# happens to match ttyd's own, or the option is no longer optional. $3 is the
# expected TTYD_TMUX_PREFIX, on the same terms.
#
# --index is unconditional: it is what replaces ttyd's stock frontend with the
# touch-capable page in web/, and it is the whole feature rather than an
# option, so an invocation without it is a regression and not a configuration.
expect_ttyd_argv() {
printf '%s\n' --interface 127.0.0.1 --port "$1" --check-origin --url-arg --writable
printf '%s\n' --index "$(dirname "$(readlink -f "$REPO_ROOT/bin")")/web/index.html"
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
# newlines, so without it a vector ending in an empty argument compares equal
# to one that does not have it.
assert_ttyd_argv() {
[ "$( { cat "$STUB_DIR/ttyd-argv"; echo .; } )" = "$( { expect_ttyd_argv "$@"; echo .; } )" ]
}
# ExecStart= with %h expanded, i.e. what systemd would actually run. Read out
# of the unit rather than repeated here, so a unit that points somewhere else
# fails these tests instead of quietly testing a path nothing uses.
unit_exec_start() {
local line
line="$(grep -m1 '^ExecStart=' "$REPO_ROOT/systemd/$1")"
line="${line#ExecStart=}"
printf '%s' "${line//%h/$2}"
}
@test "dejima-ttyd binds ttyd to loopback and takes its port from the config" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
# The default is the library's, not a second copy of it in this script.
: > "$STUB_DIR/ttyd-argv"
write_remote_config
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 7681
}
@test "dejima-ttyd refuses to start when something else already holds its port" {
command -v ss >/dev/null 2>&1 || skip "ss is not available"
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
stub_ttyd
# The distro's ttyd package ships an enabled service that binds every
# interface on this same default port. When it wins the port, this unit
# dies at every start and the reverse proxy in front of it serves THAT -
# a host login prompt - behind the operator's own password. Nothing in
# that path looks broken, so the refusal here is the only thing that says
# what happened.
#
# Driven from python so the listener cannot outlive the test: the socket
# dies with the interpreter no matter how the assertions below go. A
# background process holding it would leak on the first failure, since
# bats runs test bodies under errexit.
#
# Both halves in one run, against ONE port: bound, then closed. Without
# the second half a script that refused unconditionally would pass.
run python3 - "$REPO_ROOT/bin/dejima-ttyd" "$CONFIG_DIR" <<'PY'
import os, socket, subprocess, sys
script, config_dir = sys.argv[1], sys.argv[2]
s = socket.socket()
s.bind(("127.0.0.1", 0))
s.listen(1)
port = s.getsockname()[1]
with open(os.path.join(config_dir, "config"), "w") as f:
f.write("[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = %d\n" % port)
held = subprocess.run([script], capture_output=True, text=True)
s.close()
free = subprocess.run([script], capture_output=True, text=True)
print("PORT", port)
print("HELD_RC", held.returncode)
print("FREE_RC", free.returncode)
print("HELD_ERR", held.stderr.replace("\n", " "))
PY
[ "$status" -eq 0 ]
local port
port="$(printf '%s\n' "$output" | sed -n 's/^PORT //p')"
[ -n "$port" ]
printf '%s\n' "$output" | grep -qx 'HELD_RC 1'
printf '%s\n' "$output" | grep -qx 'FREE_RC 0'
# Names the port, and names the service that is usually holding it - a
# bare "address in use" is what this exists to replace.
printf '%s\n' "$output" | grep -q "HELD_ERR .*$port"
printf '%s\n' "$output" | grep -q 'HELD_ERR .*ttyd\.service'
# And it refused rather than proceeding: exactly one ttyd launch reached
# the stub, the one made after the port was released. A check that printed
# a warning and exec'd anyway would leave two.
[ "$(ttyd_log | grep -c -- '--interface')" -eq 1 ]
}
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
# The reason matters: on a machine with no ttyd this test passes for free.
[ -z "$(ttyd_log)" ]
}
@test "dejima-ttyd reports a missing ttyd instead of exec-ing into nothing" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config
# A PATH holding only what the script itself needs, so the absence of ttyd
# is a property of this test rather than of whoever runs it.
STUB_DIR="$(mktemp -d)"
for tool in readlink dirname cat; do
ln -s "$(command -v "$tool")" "$STUB_DIR/$tool"
done
run env PATH="$STUB_DIR" "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
# The exact message, not just a failure: an unwritten script is "not
# found" too, and `exec ttyd` with no ttyd says so in bash's words rather
# than in words that tell an operator what to install.
[[ "$output" == *"ttyd is not installed"* ]]
# And which ttyd: -W/--writable does not exist before 1.7.4 (1.7.3 and
# earlier have --readonly and are writable by default), so an older
# package makes every start a getopt usage error. An operator reading
# "install ttyd" would install exactly that. Anchored to the headline
# rather than matched anywhere in the output: the version is named twice,
# so a loose match lets the line an operator actually reads say 1.6.0.
[[ "$output" == *"needs it, 1.7.4 or newer"* ]]
}
@test "dejima-ttyd reports a broken config before it reports a missing ttyd" {
# An operator missing both pieces is otherwise sent to install a package,
# after which they hit the identical failure having never been told that
# their config is the other half of it. The config error is also the one
# they cannot work out for themselves.
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 99999'
# The same isolated PATH the missing-ttyd test uses, so the absence is a
# property of this test rather than of whoever runs it.
STUB_DIR="$(mktemp -d)"
for tool in readlink dirname cat; do
ln -s "$(command -v "$tool")" "$STUB_DIR/$tool"
done
run env PATH="$STUB_DIR" "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"invalid value"* ]]
[[ "$output" == *"ttyd_port"* ]]
[[ "$output" != *"ttyd is not installed"* ]]
}
@test "dejima-ttyd starts against a config containing a [forge.<name>] section" {
# Regression test: dejima-project-lib.sh used to source only
# dejima-config-lib.sh, leaving _config_extra_section_known's refuse-
# everything stub in place, so any config with a [forge.*]/[harness.*]
# section - including the one install.sh itself generates - died with
# "unknown section [forge.gitea]" in every remote-access CLI. Confirmed
# directly against the unpatched source before this fix.
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config '[forge.gitea]' 'ssh_host = git.example' \
'api = https://api.example/v1'
STUB_DIR="$(mktemp -d)"
for tool in readlink dirname cat; do
ln -s "$(command -v "$tool")" "$STUB_DIR/$tool"
done
run env PATH="$STUB_DIR" "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" != *"unknown section"* ]]
# Got past config validation to the next check, same as the missing-ttyd
# test above - proof the section itself was accepted, not that this
# particular failure mode is silent about it.
[[ "$output" == *"ttyd is not installed"* ]]
}
@test "dejima-notify-watch starts against a config containing a [forge.<name>] section" {
conf_with_ntfy
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\n' \
>> "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[[ "$output" != *"unknown section"* ]]
}
@test "a malformed config takes down the scripts that read a value, not dejima-attach" {
# dejima-attach reads no VALUE out of the config - remote_enabled only
# asks whether [remote-access] enabled is on, through its own parser
# rather than config_get, so a bad line elsewhere cannot take away the
# terminal an operator would fix that typo from. Everything that DOES
# read a value still goes through config_get, which validates the WHOLE
# file, so it still refuses - that contrast is the point of keeping
# remote_enabled off config_get.
write_remote_config 'this is not an assignment'
stub_docker_run_id 0123456789abcdef
write_entry 0123456789abcdef '' ''
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"* ]]
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"malformed"* ]]
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ]
[[ "$output" == *"malformed"* ]]
}
@test "dejima-ttyd refuses an out-of-range ttyd_port rather than launching without one" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 99999'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
# Named, so this cannot pass because ttyd was missing or the config was.
[[ "$output" == *"invalid value"* ]]
[[ "$output" == *"ttyd_port"* ]]
[ -z "$(ttyd_log)" ]
}
@test "ttyd_font_size reaches ttyd as an option, and its absence sends none at all" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset first. The point of the default is that no -t is sent, leaving
# ttyd on whatever its own default is - not that we send a number that
# currently happens to agree with it.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
run grep -cx -- '-t' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
# Set. Checked through the exact vector, not a substring: "fontSize=20
# arrived somewhere" would also pass if it were welded onto another
# argument, and this is a value from a config file reaching an exec.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20
}
@test "dejima-ttyd refuses a ttyd_font_size outside its range rather than launching with one" {
# A size large enough to make a single glyph wider than the viewport
# renders a terminal that displays nothing, which an operator reads as a
# broken front door rather than as a typo in a file three steps back. Same
# treatment ttyd_port gets, and for the same reason: refuse at the config,
# where the message can name the key.
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
local bad
for bad in 0 7 73 999 abc 1e2 -20; do
stub_ttyd
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ] || { echo "accepted ttyd_font_size=$bad" >&2; false; }
# Named, so this cannot pass because ttyd was missing or the port was
# bad - what must not happen is a launch.
[[ "$output" == *"ttyd_font_size"* ]] \
|| { echo "refused ttyd_font_size=$bad without naming it: $output" >&2; false; }
[ -z "$(ttyd_log)" ] || { echo "launched ttyd despite ttyd_font_size=$bad" >&2; false; }
done
}
@test "ttyd_tmux_prefix reaches ttyd as an option, and its absence sends none at all" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset means the page falls back to stock tmux's C-b on its own. Sending
# "tmuxPrefix=C-b" here instead would look identical today and diverge the
# moment either default moves.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
run grep -c 'tmuxPrefix' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_tmux_prefix = C-a'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 '' C-a
# Both knobs at once, since they share the -t mechanism and an array
# spliced in the wrong order would still "contain" each of them.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' 'ttyd_tmux_prefix = C-Space'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20 C-Space
}
@test "dejima-ttyd refuses a ttyd_tmux_prefix it cannot parse rather than launching with one" {
# The page refuses an unparseable prefix too, and says so - but it says so
# on the phone, hours later, to whoever happens to press PREFIX. Refusing
# here names the key and the file while the operator is still looking at
# them.
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
local bad
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
stub_ttyd
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ] || { echo "accepted ttyd_tmux_prefix=$bad" >&2; false; }
[[ "$output" == *"ttyd_tmux_prefix"* ]] \
|| { echo "refused ttyd_tmux_prefix=$bad without naming it: $output" >&2; false; }
[ -z "$(ttyd_log)" ] || { echo "launched ttyd despite ttyd_tmux_prefix=$bad" >&2; false; }
done
}
@test "dejima-ttyd refuses to launch without the terminal page rather than falling back to ttyd's" {
# ttyd serves its own frontend when --index is absent, which would leave a
# front door that works and a phone that has silently lost every gesture,
# the toolbar and pane resizing - with nothing anywhere saying why. So the
# missing file has to be a refusal that names it.
#
# Run from a copy of bin/ with no web/ beside it, which is what an
# incomplete checkout looks like from the script's point of view: it
# resolves the page relative to its own resolved location.
local fake="$BATS_TEST_TMPDIR/incomplete"
mkdir -p "$fake/bin"
# Every library the launcher sources, so the copy fails on the missing
# page rather than on a missing lib - which would pass the status check
# below while proving nothing.
cp "$REPO_ROOT/bin/dejima-ttyd" "$REPO_ROOT/bin/dejima-project-lib.sh" \
"$REPO_ROOT/bin/dejima-attach" "$REPO_ROOT/bin/dejima-config-lib.sh" \
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$fake/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"$fake/web/index.html"* ]]
[[ "$output" == *"build.mjs"* ]]
[ -z "$(ttyd_log)" ]
}
@test "web/index.html is regenerated whenever the sources it is built from change" {
# The page is a build artifact that is COMMITTED, so that a checkout is
# usable by copy alone and nobody needs npm to run the front door. The cost
# of that is the one failure mode a generated-and-ignored file cannot have:
# editing web/src and shipping the stale artifact, which looks like the
# edit silently not working. build.mjs writes this manifest beside the page
# from the same inputs, so a mismatch means one was not rebuilt.
local web="$REPO_ROOT/web"
[ -f "$web/index.html" ]
[ -f "$web/sources.sha256" ]
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
[ "$status" -eq 0 ]
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
echo "web/sources.sha256 is stale. Rebuild it:" >&2
echo " cd web && npm ci && node build.mjs" >&2
diff <(printf '%s\n' "$output") "$web/sources.sha256" >&2 || true
false
}
# The manifest agreeing with the sources says the MANIFEST was rebuilt. It
# says nothing about the page, and a commit that stages web/src and
# web/sources.sha256 while leaving index.html behind is exactly the shape
# this test exists to catch - it would pass everything above.
#
# build.mjs stamps the page with sha256sum of the manifest it wrote in the
# same run, so the page carrying that hash is the page having been built
# from that manifest. Two comparisons, and the pair is what closes it.
local stamp expected
stamp="$(grep -oE 'Sources: [a-f0-9]{64}' "$web/index.html" | head -1)"
stamp="${stamp#Sources: }"
expected="$(sha256sum "$web/sources.sha256" | cut -d' ' -f1)"
[ -n "$stamp" ] || { echo "web/index.html carries no Sources stamp" >&2; false; }
[ "$stamp" = "$expected" ] || {
echo "web/index.html is stale: it was built from a different manifest." >&2
echo " page stamp: $stamp" >&2
echo " manifest is: $expected" >&2
echo "Rebuild it: cd web && npm ci && node build.mjs" >&2
false
}
}
@test "the terminal page fetches nothing at page load" {
# Two reasons, either sufficient. ttyd serves exactly one file - its HTTP
# handler answers a token endpoint, a redirect and the index, and nothing
# else - so a second asset would 404 rather than load. And the sandbox
# restricts outbound traffic to an allowlist, so a CDN reference would not
# resolve even from a browser outside it if the operator is on the same
# restricted network.
local page="$REPO_ROOT/web/index.html"
[ -f "$page" ]
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
}
@test "the terminal page's own unit tests pass" {
# The pure parts - mouse report encoding, the private-mode tracker, sticky
# Ctrl, OSC 52 decoding, prefix parsing - are the parts a browser test
# could only reach through a gesture, so they are written to be checkable
# without one. Run from here rather than from a second CI job, so they
# cannot quietly stop running.
command -v node >/dev/null 2>&1 || skip "node is not installed"
run node --test "$REPO_ROOT"/web/test/*.test.js
[ "$status" -eq 0 ] || { echo "$output" >&2; false; }
}
@test "ttyd validates the WebSocket origin, and the proxy passes the Host it compares against" {
# Without --check-origin ttyd does no origin validation at all (its
# protocol.c only calls check_host_origin when the flag is set), and
# browsers do not apply the same-origin policy to WebSockets: any page in
# the operator's browser could open wss://<front-door>/ws, have the
# browser attach the cached Basic credential for that origin, and get the
# host shell - no operator interaction beyond visiting the page. auth_basic
# cannot stop that, because it is satisfied by the victim's own browser.
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
# Executed, not grepped: the flag has to reach ttyd's argv.
printf '%s\n' "$(ttyd_log)" | grep -qx -- '--check-origin'
# ttyd compares the Origin authority against the HOST HEADER, so the flag
# is only a guard if the proxy passes the client's Host through. Deleting
# this one line would leave --check-origin comparing against 127.0.0.1:port
# and refusing every real client - or, worse, quietly changing what is
# being compared. The two ship together or not at all.
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example"
[ -f "$ex" ]
# $http_host, not $host: nginx's $host has the PORT STRIPPED, and ttyd
# keeps the port in what it compares whenever it is not 80 or 443. A front
# door on 8443 - the common case, when 443 is already taken - would then
# compare "host:8443" against "host" and refuse every WebSocket, with the
# page loading fine and nothing but an lwsl_warn in the journal to say why.
grep -qE '^[[:space:]]*proxy_set_header[[:space:]]+Host[[:space:]]+\$http_host;' "$ex"
run grep -nE '\$host;' "$ex"
[ "$status" -ne 0 ]
}
@test "nothing shipped by the front doors binds a routable address" {
# Load-bearing, not stylistic. docker/init-firewall.sh:187 accepts
# container OUTPUT to the default-route address, which is the Docker
# bridge gateway - the host - and attaching tmux gives a host shell. A
# listener on any address the container can route to is the sandbox's own
# escape hatch. Loopback is not one of those addresses.
local f
for f in bin/dejima-ttyd systemd/dejima-ttyd.service \
systemd/dejima-notify-watch.service docs/nginx-dejima.conf.example; do
# Without this, every negative grep below passes on a missing file.
[ -f "$REPO_ROOT/$f" ]
done
grep -q -- '--interface 127\.0\.0\.1' "$REPO_ROOT/bin/dejima-ttyd"
# Not just "no 0.0.0.0": the only address any of these files may name is
# the loopback one dejima-ttyd binds and nginx proxies to.
run bash -c 'grep -rhoE "([0-9]{1,3}\.){3}[0-9]{1,3}" "$@" | sort -u' _ \
"$REPO_ROOT/bin/dejima-ttyd" "$REPO_ROOT/systemd" \
"$REPO_ROOT/docs/nginx-dejima.conf.example"
[ "$status" -eq 0 ]
[ "$output" = "127.0.0.1" ]
}
@test "the shipped front-door artifacts carry no real network details" {
# This repo is public. No operator's hostnames, LAN addresses or ntfy
# topics belong in it - not even in a comment. Every host in these files
# is an <angle-bracket> placeholder, so anything that could resolve to a
# real machine is a mistake.
local files=("$REPO_ROOT/bin/dejima-ttyd" "$REPO_ROOT/systemd"
"$REPO_ROOT/docs/nginx-dejima.conf.example") f
for f in "${files[@]}"; do
# Without this, both greps below "find nothing" in a missing file.
[ -e "$f" ]
done
# Every absolute URL has to be a name that cannot belong to anyone: the
# loopback address, or an RFC 2606 documentation name. This is the rule
# that catches an ntfy topic, which is always a URL.
run bash -c 'grep -rhoiE "https?://[a-z0-9.:@_-]+" "$@" \
| grep -viE "^https?://(127\.0\.0\.1(:[0-9]+)?|(www\.)?example\.(com|net|org)|[a-z0-9-]+\.invalid)"' \
_ "${files[@]}"
[ "$status" -ne 0 ]
[ -z "$output" ]
# And no private-network or mDNS suffix in any form, URL or not. The
# leading host character is what keeps this off ~/.local/bin, which is
# where these units point.
run grep -rniE '[a-z0-9-]\.(localdomain|lan|local|home|internal|arpa)([^a-z0-9/-]|$)' "${files[@]}"
[ "$status" -ne 0 ]
# And the placeholders really are placeholders.
grep -q '<LAN-IP>' "$REPO_ROOT/docs/nginx-dejima.conf.example"
grep -q '<hostname>' "$REPO_ROOT/docs/nginx-dejima.conf.example"
}
# One line per server block in the example: its listen line and whether the
# block authenticates, proxies, refuses, and is the default for its address.
#
# Per BLOCK rather than per file, because every property these tests care
# about is a per-block directive. A file-wide `grep -q auth_basic` passes on a
# file whose second block has none, and that block is the open door. Braces
# are counted rather than matched on indentation, so the `location` block
# nested inside a server doesn't end it early.
nginx_blocks() {
awk '
function count(s, ch, n, i) {
n = 0
for (i = 1; i <= length(s); i++) if (substr(s, i, 1) == ch) n++
return n
}
!inb && /^[[:space:]]*server[[:space:]]*\{/ {
inb = 1; depth = 0
listen = "NONE"; auth = 0; pass = 0; reject = 0; def = 0; subf = 0
}
inb {
if ($0 ~ /^[[:space:]]*listen[[:space:]]/) listen = $0
if ($0 ~ /^[[:space:]]*auth_basic[[:space:]]+"/) auth = 1
if ($0 ~ /^[[:space:]]*proxy_pass[[:space:]]/) pass = 1
if ($0 ~ /^[[:space:]]*ssl_reject_handshake[[:space:]]+on;/ ||
$0 ~ /^[[:space:]]*return[[:space:]]+444;/) reject = 1
if ($0 ~ /default_server/) def = 1
if ($0 ~ /^[[:space:]]*sub_filter[[:space:]]/) subf = 1
depth += count($0, "{") - count($0, "}")
if (depth <= 0) {
printf "auth=%d pass=%d reject=%d def=%d sub=%d listen=<%s>\n", \
auth, pass, reject, def, subf, listen
inb = 0
}
}
' "$1"
}
@test "the nginx example cannot be loaded as-is and never binds every interface" {
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example" block n=0
[ -f "$ex" ]
grep -q '/path/to/' "$ex"
while IFS= read -r block; do
n=$((n + 1))
# Every block, every listen. A bare `listen 443 ssl;` binds every
# interface including the bridge address a container can reach, and a
# block with NO listen at all is not caught by grepping listen lines -
# nginx defaults it to *:80, the same wildcard bind in the clear.
[[ "$block" == *"listen=<"*"<LAN-IP>:"* ]] \
|| { echo "block $n does not listen on the placeholder address: $block" >&2; false; }
[[ "$block" == *" ssl"* || "$block" == *" ssl;"* ]] \
|| { echo "block $n listens without ssl: $block" >&2; false; }
done < <(nginx_blocks "$ex")
[ "$n" -ge 2 ]
}
@test "the nginx example refuses every hostname but its own" {
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example" block defaults=0
[ -f "$ex" ]
# nginx serves the DEFAULT server for a listen address whenever the Host
# matches no server_name, and with a single block that default IS the
# front door - so it answers to every name resolving to that address, the
# box's own included, and server_name isolates nothing. The example has to
# ship a default that refuses, or the isolation it appears to describe
# does not exist.
while IFS= read -r block; do
case "$block" in
*"def=1"*)
defaults=$((defaults + 1))
# The default block must be the one that serves NOTHING.
[[ "$block" == *"reject=1"* ]] \
|| { echo "the default server does not refuse: $block" >&2; false; }
[[ "$block" == *"pass=0"* ]] \
|| { echo "the default server proxies to ttyd: $block" >&2; false; }
;;
esac
done < <(nginx_blocks "$ex")
[ "$defaults" -eq 1 ]
grep -qE '^[[:space:]]*server_name[[:space:]]+<hostname>;' "$ex"
}
@test "the nginx example proxies to the port dejima-ttyd defaults to, over a connection that can idle" {
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example" port
[ -f "$ex" ]
# Taken from the library rather than hardcoded, so the example and the
# default can never drift apart without this failing.
write_remote_config
run lib remote_ttyd_port
[ "$status" -eq 0 ]
port="$output"
[ -n "$port" ]
grep -qF "proxy_pass http://127.0.0.1:$port;" "$ex"
grep -qE '^[[:space:]]*proxy_http_version[[:space:]]+1\.1;' "$ex"
grep -qE '^[[:space:]]*proxy_set_header[[:space:]]+Upgrade[[:space:]]+\$http_upgrade;' "$ex"
grep -qiE '^[[:space:]]*proxy_set_header[[:space:]]+Connection[[:space:]]+"upgrade";' "$ex"
# An attached terminal can sit idle for hours, so the read timeout has to
# be measured in hours or days - nginx's own default is 60 seconds.
run grep -hoE 'proxy_read_timeout[[:space:]]+[0-9]+[smhd]?' "$ex"
[ "$status" -eq 0 ]
[[ "$output" =~ [0-9]+[hd]$ ]]
}
@test "the terminal page ships its own viewport meta" {
# ttyd serves no <meta name="viewport"> - not in 1.7.7, not on main - so a
# phone lays the page out at a virtual desktop width and scales it down,
# which no font size can undo and which zooming cannot reflow. The page
# supplies the tag itself, so the layout does not depend on the reverse
# proxy in front of it rewriting the body.
local page="$REPO_ROOT/web/index.html"
[ -f "$page" ]
grep -qE '<meta name="viewport"[^>]*width=device-width' "$page"
# user-scalable=no is not tidiness: a two-finger drag is this page's scroll
# gesture, and pinch-zoom would consume it.
grep -qE '<meta name="viewport"[^>]*user-scalable=no' "$page"
# And the example no longer injects one. Two tags would not conflict, but
# the sub_filter would silently reintroduce its own requirements - an nginx
# built with the sub module, and Accept-Encoding cleared toward the
# upstream - whose only symptom when unmet is the tag appearing not to work.
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example"
[ -f "$ex" ]
run grep -nE '^[[:space:]]*sub_filter' "$ex"
[ "$status" -ne 0 ]
# The cache header stays, for a reason that survived the change and got
# stronger: ttyd sends the page with no Cache-Control, no ETag and no
# Last-Modified, so a browser may cache it indefinitely - and the page is
# now a build artifact that moves with this repo, so a phone that has been
# here before renders whatever it first saw.
grep -qE '^[[:space:]]*add_header[[:space:]]+Cache-Control[[:space:]]+"no-store"' "$ex"
}
@test "the nginx example carries the only authentication there is" {
local ex="$REPO_ROOT/docs/nginx-dejima.conf.example"
[ -f "$ex" ]
grep -qE '^[[:space:]]*auth_basic[[:space:]]' "$ex"
grep -qE '^[[:space:]]*auth_basic_user_file[[:space:]]' "$ex"
grep -qE '^[[:space:]]*ssl_certificate[[:space:]]' "$ex"
# Judged per block, because auth is a per-block directive: one block with
# it and one without passes every "is it present" grep and still leaves an
# open door. The rule is about what a block SERVES - anything that can
# reach ttyd needs authentication, and a block with none is only allowed
# if it serves nothing at all.
# A realm, not just the directive: `auth_basic off;` IS an auth_basic line
# and turns authentication off for the block it is in.
local block proxies=0
while IFS= read -r block; do
case "$block" in
*"pass=1"*)
proxies=$((proxies + 1))
[[ "$block" == *"auth=1"* ]] \
|| { echo "a block proxies to ttyd without authentication: $block" >&2; false; }
;;
*"auth=0"*)
[[ "$block" == *"reject=1"* ]] \
|| { echo "a block without authentication serves something: $block" >&2; false; }
;;
esac
done < <(nginx_blocks "$ex")
[ "$proxies" -ge 1 ]
run grep -nE '^[[:space:]]*auth_basic[[:space:]]+off' "$ex"
[ "$status" -ne 0 ]
# ttyd is started --writable and with no credential of its own, so nginx
# is the whole gate. If that ever changes, both halves have to change
# together rather than one of them drifting.
grep -q -- '--writable' "$REPO_ROOT/bin/dejima-ttyd"
run grep -nE -- '--credential|[[:space:]]-c[[:space:]]' "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
}
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
# The hook is linked by the harness plugin now - which file it is, and
# where the harness reads it from, is the harness's business - while the
# CLIs stay install.sh's, being dejima's own.
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
# The notify hook is registered per session by bin/dejima, into a
# throwaway settings copy. Registered here it would fire for every
# unsandboxed host session too.
run grep -nE '_register .*dejima-notify' "$REPO_ROOT/bin/harness/claude-code.sh"
[ "$status" -ne 0 ]
# Standing up a front door is an explicit operator decision. Nothing in
# the installer may start, enable or even copy a unit. Code only - the
# comment that says the script never touches systemd is not a violation
# of itself.
run bash -c 'grep -vE "^[[:space:]]*#" "$1" | grep -nE "systemctl|loginctl|systemd"' \
_ "$REPO_ROOT/install.sh"
[ "$status" -ne 0 ]
}
@test "install.sh puts the notify hook and the remote scripts where the container and the units expect them" {
FAKE_HOME="$(mktemp -d)"
STUB_DIR="$(mktemp -d)"
# install.sh checks for docker and `docker compose version`; jq and
# python3 are real, and it needs them to be.
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
chmod +x "$STUB_DIR/docker"
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
# Asserted here rather than further down: every `run` below replaces
# $output, and this is a statement about what THIS run printed.
[[ "$output" == *"nothing is listening"* ]]
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
# and ~/.claude/hooks is what gets mounted read-only into the container.
# Without this link every Notification and Stop is an exit-127.
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
[ -x "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
local b
for b in dejima-attach dejima-ttyd dejima-notify-watch; do
[ -L "$FAKE_HOME/.local/bin/$b" ]
[ "$(readlink -f "$FAKE_HOME/.local/bin/$b")" = "$(readlink -f "$REPO_ROOT/bin/$b")" ]
[ -x "$FAKE_HOME/.local/bin/$b" ]
done
# Positive first: an absence assertion against a settings.json that did
# not get written would pass on its own.
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("sandbox-context.sh"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -eq 0 ]
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -ne 0 ]
# Nothing was enabled, and nothing under the runtime dir was created: the
# feature stays off until the operator sets [remote-access] enabled = on.
[ ! -e "$RUNTIME_DIR/dejima" ]
[ ! -e "$FAKE_HOME/.config/systemd" ]
# Re-runnable: the second pass displaces nothing into the backup dir.
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
[ ! -e "$FAKE_HOME/.dejima-backup" ]
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
# And with one, it may not: re-running the installer after a git pull is
# exactly what an operator with a front door already up does, and telling
# them nothing is listening is a false statement about their own machine.
write_remote_config
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"nothing is listening"* ]]
[[ "$output" == *"$CONFIG_DIR/config"* ]]
# With DEJIMA_CONFIG_DIR unset, which is how every operator runs it: the
# installer has to look where the library looks. Everything above runs
# with the variable exported, so the default path is otherwise never
# exercised and could point anywhere.
mkdir -p "$FAKE_HOME/.config/dejima"
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\n' > "$FAKE_HOME/.config/dejima/config"
run env -u DEJIMA_CONFIG_DIR HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" \
"$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"nothing is listening"* ]]
[[ "$output" == *"$FAKE_HOME/.config/dejima/config"* ]]
}
@test "the systemd units are user units that run the installed scripts and duplicate no configuration" {
local u f
for u in dejima-ttyd dejima-notify-watch; do
f="$REPO_ROOT/systemd/$u.service"
[ -f "$f" ]
grep -q "^ExecStart=%h/\.local/bin/$u\$" "$f"
grep -q '^WantedBy=default\.target$' "$f"
# Directives only, from here down: these files carry comments that
# name the very things a directive may not set ("do not repeat
# NTFY_URL here"), and a grep that could not tell the two apart would
# force those comments out of the file to stay green.
# Restarting on failure, not always: these units exist to be given up
# on when base_url is unset or the config is invalid, which is a
# clean-ish exit away from Restart=always.
grep -q '^Restart=on-failure$' "$f"
# A WHITELIST of directive names, not a list of banned ones: a denylist
# only ever catches the directives someone thought of, and these units
# run a host shell - StandardOutput=file:..., User=, Environment=,
# ExecStartPre= are all things that change what runs or where its
# output goes. Anything not on this line has to be added deliberately -
# including a systemd line continuation, whose second line carries no
# "=" and so fails this. That is the intended trade: these units are
# five directives long and none of them needs one.
run bash -c 'grep -vE "^[[:space:]]*([#;]|$)" "$1" \
| grep -vxE "\[(Unit|Service|Install)\]" | sed "s/=.*//" | sort -u \
| grep -vxE "Description|StartLimitIntervalSec|StartLimitBurst|ExecStart|Restart|RestartSec|WantedBy"' \
_ "$f"
[ "$status" -ne 0 ]
# The whitelist covers directive NAMES; the rest of this test is about
# their VALUES. No absolute home - %h is what makes these units the
# same file for every operator.
run bash -c 'grep -vE "^[[:space:]]*[#;]" "$1" | grep -nE "/home/|/root/|/Users/"' _ "$f"
[ "$status" -ne 0 ]
# remote.conf is the single source of truth for all of these. A unit
# that repeated one would drift from the script that reads it.
run bash -c 'grep -vE "^[[:space:]]*[#;]" "$1" | grep -nE "TTYD_PORT|NTFY_URL|BASE_URL|--port|--interface"' _ "$f"
[ "$status" -ne 0 ]
done
}
@test "the ttyd unit's ExecStart launches ttyd with nothing but what systemd gives a user unit" {
# systemd starts a user unit with the user manager's environment, not the
# operator's shell's. env -i with the floor systemd guarantees (HOME,
# PATH, XDG_RUNTIME_DIR) is what makes this test worth having: a script
# that works only because some interactive-shell variable happened to be
# set fails here rather than on the operator's phone.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.local/bin" "$FAKE_HOME/.config/dejima"
ln -s "$REPO_ROOT/bin/dejima-ttyd" "$FAKE_HOME/.local/bin/dejima-ttyd"
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
> "$FAKE_HOME/.config/dejima/config"
stub_ttyd
local exec_start
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
[ "$exec_start" = "$FAKE_HOME/.local/bin/dejima-ttyd" ]
run env -i HOME="$FAKE_HOME" PATH="$STUB_DIR:/usr/bin:/bin" \
XDG_RUNTIME_DIR="$RUNTIME_DIR" "$exec_start"
[ "$status" -eq 0 ]
# %h resolved to the install destination, the symlink resolved back to
# this repo (the command below is the repo's dejima-attach, not the
# link), and the config was found at the default config path with no
# DEJIMA_CONFIG_DIR in the environment to point at it.
assert_ttyd_argv 12345
}
@test "the notify-watch unit's ExecStart finds the markers in the runtime dir systemd gives it" {
# The highest-risk integration point in the feature. bin/dejima resolves
# ${XDG_RUNTIME_DIR:-/tmp}/dejima from the operator's shell; this unit
# resolves it from the user manager's environment. If those ever disagree
# the watcher watches an empty directory and no notification is ever
# pushed - with no error anywhere to say so.
conf_with_ntfy
stub_curl
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.local/bin" "$FAKE_HOME/.config/dejima"
ln -s "$REPO_ROOT/bin/dejima-notify-watch" "$FAKE_HOME/.local/bin/dejima-notify-watch"
cp "$CONFIG_DIR/config" "$FAKE_HOME/.config/dejima/config"
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
local exec_start
exec_start="$(unit_exec_start dejima-notify-watch.service "$FAKE_HOME")"
[ "$exec_start" = "$FAKE_HOME/.local/bin/dejima-notify-watch" ]
# --once bounds the scan; the unit's own argv is the line asserted above.
run env -i HOME="$FAKE_HOME" PATH="$STUB_DIR:/usr/bin:/bin" \
XDG_RUNTIME_DIR="$RUNTIME_DIR" "$exec_start" --once
[ "$status" -eq 0 ]
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ]
}
# The whole doc this feature is documented in. It used to be one "## Remote
# access" section awk'd out of the README, and the carving was the point: a
# string that only has to appear *somewhere* in a 900-line README is satisfied
# by an unrelated section. Its own file gives that for free.
remote_doc() {
cat "$REPO_ROOT/docs/remote-access.md"
}
@test "the doc the front doors point readers at exists at the path they name" {
# bin/dejima-ttyd, install.sh and five more end their diagnostics with
# `See docs/remote-access.md`. Nothing else in the repo notices when one
# side of that moves: the scripts still run, the doc still reads fine, and
# the operator who most needs it is sent nowhere. The path is READ OUT of
# the script rather than written here twice, so a rename on the code side
# fails this test instead of quietly passing it.
#
# Normalized before matching, because the reference is not written the same
# way twice: dejima-ttyd carries it on one comment line, while
# dejima-attach's and the notify hook's wrap across two. Joining lines and
# squashing '#' and runs of spaces makes all three the same string without
# making the pattern itself lax.
ref() { tr '\n' ' ' < "$1" | sed 's/#/ /g; s/ */ /g'; }
local doc
# head -n1 rather than grep -m1: the ref() above joins the file into a
# single line, so -m1 stops after the first matching LINE and still prints
# every match on it. A script that names the doc twice would otherwise
# produce a two-line "path" and fail this on the wrong thing entirely.
doc="$(ref "$REPO_ROOT/bin/dejima-ttyd" | grep -oE 'docs/[a-z-]+\.md' | head -n1)"
[ -n "$doc" ]
[ -f "$REPO_ROOT/$doc" ]
# Reachable from the README too, not only from the scripts: the section it
# was extracted from is now a stub whose one job is to link here.
grep -qF "($doc)" "$REPO_ROOT/README.md"
# And every other file that points at this feature points at the SAME
# path - one stale pointer is as dangling as one moved file.
local f
for f in bin/dejima-attach bin/dejima-ttyd bin/dejima-notify-watch \
bin/dejima bin/dejima-project-lib.sh hooks/dejima-notify.sh install.sh; do
ref "$REPO_ROOT/$f" | grep -qF "$doc" || {
echo "$f does not point at $doc" >&2
false
}
done
}
@test "docs/remote-access.md documents every remote access setup step" {
local section
section="$(remote_doc)"
[ -n "$section" ]
# has <-F|-E> <pattern>. Fails the test on the first missing item, naming
# it - a bare grep chain reports only a line number. The pattern goes
# through -e because half of what's looked for below is a command-line
# flag, which grep would otherwise try to interpret as its own.
has() {
printf '%s\n' "$section" | grep -q "$1" -e "$2" || {
echo "docs/remote-access.md is missing: $2" >&2
return 1
}
}
# Opt-in, and the config key whose value is the switch.
has -F '[remote-access]'
has -F 'enabled = on'
has -F '~/.config/dejima/config'
# Every key the [remote-access] section accepts. This list used to be
# derived from valid_remote_key(); that function and remote.conf are both
# gone (see bin/dejima-project-lib.sh), and the keys now live in the
# config schema (_config_table in bin/dejima-config-lib.sh). Pinned here
# rather than derived so this still catches the doc losing one of them.
local keys key
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix'
for key in $keys; do has -F "\`$key\`"; done
# The port default, taken from the library so the two cannot drift.
write_remote_config
run lib remote_ttyd_port
[ "$status" -eq 0 ]
has -F "$output"
# The binding requirement, which is the whole security of the feature:
# ttyd on loopback, the proxy on one specific address, and the reason -
# the container can route to the bridge gateway, and a tmux client is a
# host shell.
has -F '127.0.0.1'
has -F '0.0.0.0'
has -E 'bridge gateway'
has -E 'host shell'
# As a step the operator performs, not only as prose: a probe run from
# inside a live sandbox at the host's own default-route address. Matched
# loosely on purpose - that some runnable check is there is the point,
# not which tool it reaches for.
has -E 'ip route|default route'
has -E '/dev/tcp|\bnc |ncat|curl '
# Both front doors, the pieces each one needs, and the hook and watcher
# behind the notification.
has -F 'authorized_keys'
has -F 'restrict,pty,command='
has -F 'dejima-attach'
has -F 'hooks/dejima-notify.sh'
has -F 'docs/nginx-dejima.conf.example'
has -F 'systemctl --user enable'
# The step that keeps those user units - and the session registry that
# lives in the runtime directory they read - alive across a logout. An
# operator who skips it has a front door that works only while they hold
# an interactive session on the host, which is the case the feature
# exists for.
has -F 'enable-linger'
# Derived from what the repo ships: a third unit cannot be added and left
# undocumented. (Full names, so "dejima-ttyd" alone does not satisfy it.)
local unit
for unit in "$REPO_ROOT"/systemd/*; do has -F "$(basename "$unit")"; done
# ttyd's two load-bearing flags, and the version floor --writable needs.
# An operator who installs an older ttyd gets a unit that fails at every
# start, or - worse, before 1.7.4 inverted the default - a front door
# whose only gate is the proxy.
has -F '--check-origin'
has -F '--writable'
has -F '1.7.4'
# The header --check-origin compares against, in the form that keeps the
# port. The negative test next door forbids the other form; this one
# makes sure the right one is actually named.
has -F '$http_host'
# The notification: what it says, what it deliberately does not, and the
# deep link's shape.
has -F 'ntfy'
has -F 'inotify-tools'
has -F 'needs input'
has -F '?arg='
}
@test "docs/remote-access.md repeats no advice that would break or unbolt the front door" {
local section
section="$(remote_doc)"
[ -n "$section" ]
# Both checks below run against the doc's FENCED BLOCKS only - what a
# reader copies out. Applied to the whole doc they would forbid the prose
# that explains why each is wrong, which is the opposite of what this doc
# needs to say.
local blocks
blocks="$(printf '%s\n' "$section" | awk '/^```/ { inblock = !inblock; next } inblock')"
[ -n "$blocks" ]
# $host, not $http_host, is the single most repeatable way to break this
# setup: nginx's $host has the port stripped, ttyd keeps the port in what
# --check-origin compares unless it is 80 or 443, and a vhost on any
# other port then refuses every WebSocket while serving the page fine.
# docs/nginx-dejima.conf.example is already guarded against it; this doc
# is the other place the line gets copied from.
run bash -c 'printf "%s\n" "$1" | grep -nE "\\\$host([^_a-zA-Z]|$)"' _ "$blocks"
[ "$status" -ne 0 ]
# No bare package-manager one-liner for ttyd. A distro's default suite is
# where the too-old builds are - bookworm 1.7.3, Ubuntu 22.04 1.6.3 - so an
# untargeted apt line here is an instruction that yields a broken front
# door, and a command in a fenced block is what gets copied, not the caveat
# beside it. An explicitly release-targeted install (sid packages 1.7.7) is
# the one form that says which build you get, so only that is allowed.
run bash -c 'printf "%s\n" "$1" | grep -nE "apt(-get)? install .*ttyd" \
| grep -vE "install +-t +(unstable|sid) "' _ "$blocks"
[ "$status" -ne 0 ]
# And a recipe that adds unstable has to pin it, or it is an instruction to
# drag the whole host to sid on the next upgrade.
if printf '%s\n' "$blocks" | grep -qE '\bunstable\b|\bsid\b'; then
printf '%s\n' "$blocks" | grep -q 'Pin-Priority'
fi
# Any instruction to install ttyd from a package has to be accompanied by
# the one that disables the service the package turns on. Debian/Ubuntu
# enable it on install; it serves a login prompt and, passing no
# --interface, binds every address - so it is reachable from inside a
# sandbox, on the port this feature defaults to, purely as a result of
# following the install step above it. Documenting the install without the
# disable is worse than documenting neither.
# Both halves, asserted separately: disable stops the running listener,
# mask is what stops the next `apt install` from turning it back on, and
# an alternation over the two passes on a file that only has one.
if printf '%s\n' "$blocks" | grep -qE 'apt(-get)? install .*ttyd'; then
printf '%s\n' "$blocks" | grep -qE 'systemctl +disable.*ttyd'
printf '%s\n' "$blocks" | grep -qE 'systemctl +mask.*ttyd'
fi
# This repo is public, and this doc is where an operator's own setup is
# most likely to leak in. Every absolute URL has to be loopback, a name
# that cannot belong to anyone (RFC 2606), or one of a short list of public
# infrastructure hosts nobody could mistake for a personal one.
# test/static.bats catches address- and suffix-shaped leaks across the
# README and docs/; this catches a real ntfy topic, which is always a URL.
run bash -c 'printf "%s\n" "$1" | grep -hoiE "https?://[a-z0-9.:@_-]+" \
| grep -viE "^https?://(127\.0\.0\.1(:[0-9]+)?|(www\.)?example\.(com|net|org)|[a-z0-9-]+\.(invalid|example|test)|deb\.debian\.org)"' \
_ "$section"
[ "$status" -ne 0 ]
[ -z "$output" ]
}