#!/bin/sh
# ACCOUNT ROOTS (ADR-0010) — the alt-profile conformance suite: run a session under a DIFFERENT
# Anthropic account without logging out of the first.
#
# Two halves, both required:
#   A. MANIFEST guards (always run) — the shipped `:alt` overlay covers EVERY spawned role and
#      threads the account into each. A profile that overlays the session but not the Psyche is not
#      a failover: the Psyche would keep drawing on the account the operator just failed away from.
#   B. BEHAVIOR (runs against the built binary) — init really builds the layout, the continuity
#      junctions really share, NOTHING account-bound is shared, trust is really seeded, a launch
#      against a missing root ERRORS naming init, and a launch with no credentials PROCEEDS.
#
# The behavioral half proves sharing/non-sharing FUNCTIONALLY (write here, read there) rather than
# by asking the shell whether a path "is a symlink": MSYS classifies Windows junctions
# inconsistently, and a test that reads the reassuring answer from a broken instrument is worse
# than no test (the 2026-08-21 grep-instrument lesson). The link-TYPE assertions live in the
# crate's own unit tests, which use std's reparse-aware metadata.
#
# Run: sh tests/alt-account-roots.sh (exit 0 = pass).
# [unit->REQ-ALT-ACCOUNT-ROOTS]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
cd "$ROOT" || exit 2
rc=0
ok()   { printf 'ok   %s\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; rc=1; }
skip() { printf 'SKIP: %s\n' "$1"; }

MANIFEST="adapter/claude-spt.toml"
[ -f "$MANIFEST" ] || { echo "FAIL: missing $MANIFEST"; exit 1; }

# ── A. Manifest guards ────────────────────────────────────────────────────────────────────────

# A1. Every spawned role is overlaid by the alt profile — the five that spawn a `claude`.
for role in session.self session.resume session.psyche_init session.psyche_resume session.echo_commune; do
  if grep -q "^\[profiles\.alt\.$role\]" "$MANIFEST"; then
    ok "[profiles.alt.$role] declared"
  else
    fail "[profiles.alt.$role] missing — that role would keep spawning on the DEFAULT account"
  fi
done

# A2. Every alt command leaf threads `--account`. A declared role whose command forgot the flag is
#     the silent half-failover: it looks overlaid and pays from the wrong account.
alt_cmds=$(awk '/^\[profiles\.alt\./{inalt=1} /^\[/{if ($0 !~ /^\[profiles\.alt\./) inalt=0} inalt && /^command[[:space:]]*=/' "$MANIFEST")
n_cmds=$(printf '%s' "$alt_cmds" | grep -c .)
n_acct=$(printf '%s\n' "$alt_cmds" | grep -c -- '--account ')
if [ "$n_cmds" -eq 5 ]; then ok "5 alt command leaves (self/resume/psyche_init/psyche_resume/echo_commune)"; else fail "expected 5 alt command leaves, found $n_cmds"; fi
if [ "$n_cmds" -eq "$n_acct" ]; then ok "every alt command threads --account"; else fail "$((n_cmds - n_acct)) alt command(s) missing --account: $alt_cmds"; fi

# A3. Each alt command names ONE literal account, never a `{…}` fill. spt profiles are static —
#     there is no {account} substitution key, and a template that looks parameterized would resolve
#     to a directory literally named "{account}".
if printf '%s\n' "$alt_cmds" | grep -q -- '--account {'; then
  fail "an alt command templates --account — no such fill key exists; name the account literally"
else
  ok "alt commands name their account literally (no phantom {account} fill)"
fi

# A4. The account name is a boring token (it names a directory under ~/.claude-spt/accounts/).
for a in $(printf '%s\n' "$alt_cmds" | sed -n 's/.*--account \([^ "]*\).*/\1/p'); do
  case "$a" in
    *[!A-Za-z0-9._-]*|""|.|..) fail "alt account name is not a boring token: '$a'" ;;
    *) : ;;
  esac
done
ok "alt account names are boring tokens"

# A5. The internal roles still route through the claude-spt shim (the foreign-config isolation is
#     BINARY-side; a profile rerouting them elsewhere bypasses --safe-mode silently).
bad=$(printf '%s\n' "$alt_cmds" | grep -v 'claude-spt' || true)
if [ -n "$bad" ]; then fail "an alt command bypasses the claude-spt shim: $bad"; else ok "all alt commands route through the shim"; fi

# A6. Observability leaf — an operator must be able to prove WHICH profile resolved.
if grep -q '^\[profiles\.alt\.strings\]' "$MANIFEST"; then ok "[profiles.alt.strings] present (resolve is observable)"; else fail "[profiles.alt.strings] missing"; fi

# ── B. Behavior against the built binary ──────────────────────────────────────────────────────

BIN="tools/claude-spt/target/release/claude-spt"
[ -x "$BIN.exe" ] && BIN="$BIN.exe"
if [ ! -x "$BIN" ] || ! "$BIN" --help 2>&1 | grep -q 'alt <verb>'; then
  if command -v cargo >/dev/null 2>&1; then
    cargo build --release --quiet --manifest-path tools/claude-spt/Cargo.toml >/dev/null 2>&1 || true
    BIN="tools/claude-spt/target/release/claude-spt"
    [ -x "$BIN.exe" ] && BIN="$BIN.exe"
  fi
fi
if [ ! -x "$BIN" ] || ! "$BIN" --help 2>&1 | grep -q 'alt <verb>'; then
  skip "no built claude-spt carrying the \`alt\` subcommand — behavioral half not run (build it: sh ci/digest/build.sh)"
  printf '\n=== alt-account-roots: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
  exit "$rc"
fi
BIN=$(CDPATH= cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")

# A native Windows binary cannot use an MSYS-form path in an env var; hand it the native form.
winpath() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi }

TMP=$(mktemp -d 2>/dev/null) || TMP="${TMPDIR:-/tmp}/sptc-alt-$$"
mkdir -p "$TMP" || { echo "FAIL: cannot create a temp dir"; exit 1; }
trap 'rm -rf "$TMP" 2>/dev/null || true' EXIT INT TERM

FAKE_HOME="$TMP/home"
PRIMARY="$FAKE_HOME/.claude"
mkdir -p "$PRIMARY/skills" "$PRIMARY/plugins"
printf '%s' '{"userID":"user-A","oauthAccount":{"emailAddress":"a@example.com"},"projects":{"/p/trusted":{"hasTrustDialogAccepted":true}}}' > "$PRIMARY/.claude.json"
printf '%s' '{"primary":"credentials"}' > "$PRIMARY/.credentials.json"

W_HOME=$(winpath "$FAKE_HOME")
W_PRIMARY=$(winpath "$PRIMARY")

run_alt() { HOME="$W_HOME" USERPROFILE="$W_HOME" CLAUDE_CONFIG_DIR="$W_PRIMARY" "$BIN" "$@" 2>&1; }

# B1. init builds the layout.
out=$(run_alt alt init probe); irc=$?
if [ "$irc" -eq 0 ]; then ok "alt init exits 0"; else fail "alt init exited $irc: $out"; fi
ACCT="$FAKE_HOME/.claude-spt/accounts/probe"
SHARED="$FAKE_HOME/.claude-spt/shared"
for d in projects todos file-history shell-snapshots session-env; do
  [ -d "$SHARED/$d" ] && ok "shared/$d exists" || fail "shared/$d missing"
  [ -d "$ACCT/$d" ]   && ok "accounts/probe/$d reachable" || fail "accounts/probe/$d missing"
done

# B2. The continuity set really SHARES (functional proof, not a link-type guess). Without this,
#     an account switch abandons the in-flight session: spt resume passes --resume <session_id>
#     and Claude Code reads that transcript out of the ACTIVE root.
printf 'transcript' > "$SHARED/projects/probe.jsonl"
if [ -f "$ACCT/projects/probe.jsonl" ]; then ok "projects/ is shared into the account root (resume survives a switch)"; else fail "projects/ is NOT shared — an account switch would abandon the session"; fi
printf 'todo' > "$ACCT/todos/probe.txt"
if [ -f "$SHARED/todos/probe.txt" ]; then ok "the sharing is two-way (account root writes reach the shared tree)"; else fail "a write in the account root did not reach the shared tree"; fi

# B3. NOTHING ACCOUNT-BOUND IS SHARED — the load-bearing regression. `.claude.json` takes a new
#     file identity every ~15-25s under a live session, so a linked one diverges SILENTLY.
[ -f "$ACCT/.claude.json" ] && ok "account root has its own .claude.json (real file)" || fail "account root has no .claude.json"
printf '%s' '{"userID":"user-B","projects":{}}' > "$ACCT/.claude.json"
if grep -q 'user-A' "$PRIMARY/.claude.json"; then ok ".claude.json is per-account (writing one did not touch the other)"; else fail ".claude.json is SHARED between roots — the silent-divergence bug"; fi
printf '%s' '{"alt":"credentials"}' > "$ACCT/.credentials.json"
if grep -q 'primary' "$PRIMARY/.credentials.json"; then ok ".credentials.json is per-account (one login per account, not one for all)"; else fail ".credentials.json leaked across roots"; fi
for f in .claude.json .credentials.json history.jsonl; do
  if [ -e "$SHARED/$f" ]; then fail "$f appears in the SHARED tree — account-bound state must never be shared"; fi
done
ok "no account-bound file is in the shared tree"

# B4. Trust is seeded (an unseeded root does not fail — it HANGS on the dialog).
rm -f "$ACCT/.claude.json"
out=$(run_alt alt init probe)
if grep -q '/p/trusted' "$ACCT/.claude.json" 2>/dev/null; then ok "trust seeded from the primary root"; else fail "trust NOT seeded: $out"; fi
if grep -q 'user-A' "$ACCT/.claude.json" 2>/dev/null; then fail "the primary's ACCOUNT IDENTITY was copied into the account root"; else ok "account identity did not cross roots (trust only)"; fi

# B5. init is idempotent (it is one-shot — never run at launch, so re-running is the repair path).
out=$(run_alt alt init probe); irc=$?
if [ "$irc" -eq 0 ] && [ -f "$ACCT/projects/probe.jsonl" ]; then ok "alt init is idempotent"; else fail "re-running alt init did not converge (rc=$irc): $out"; fi

# B5b. `alt init` with NO account name defaults to "alt" — the same name the shipped profile
#      bakes in, so the no-argument path and the profile agree. [unit->REQ-ALT-AUTO-INIT]
out=$(run_alt alt init); irc=$?
if [ "$irc" -eq 0 ] && [ -d "$FAKE_HOME/.claude-spt/accounts/alt" ]; then ok "bare alt init defaults to account 'alt'"; else fail "bare alt init did not build accounts/alt (rc=$irc): $out"; fi

# B6. A launch against a MISSING root AUTO-RUNS the one-shot init and PROCEEDS (request #15:
#     the user has no easy path to the binary, so first launch must build the root itself). Proven
#     by driving it at a CLI that does not exist — reaching "not found on PATH" means the launch
#     carried on past the init. [unit->REQ-ALT-AUTO-INIT]
out=$(HOME="$W_HOME" USERPROFILE="$W_HOME" CLAUDE_CONFIG_DIR="$W_PRIMARY" "$BIN" launch --id t --cli sptc-no-such-cli-xyz --account first-launch-xyz 2>&1)
case "$out" in
  *"first launch"*) ok "a missing root is announced as a first launch" ;;
  *) fail "no first-launch notice: $out" ;;
esac
AUTO="$FAKE_HOME/.claude-spt/accounts/first-launch-xyz"
[ -d "$AUTO" ] && ok "launch built the missing account root itself" || fail "launch did not create the account root"
[ -d "$AUTO/projects" ] && ok "the auto-built root is junctioned into the shared tree" || fail "auto-built root has no projects/ junction"
grep -q '/p/trusted' "$AUTO/.claude.json" 2>/dev/null && ok "the auto-built root has trust seeded" || fail "auto-init did not seed trust"
case "$out" in
  *"not found on PATH"*) ok "the launch PROCEEDED past the auto-init (reached the spawn)" ;;
  *) fail "the launch did not proceed after auto-init: $out" ;;
esac

# B6b. When the auto-init itself CANNOT succeed (no primary .claude.json to seed trust from), the
#     launch fails loudly and names the manual repair. [unit->REQ-ALT-AUTO-INIT]
BARE_HOME="$TMP/bare-home"; mkdir -p "$BARE_HOME"
W_BARE=$(winpath "$BARE_HOME")
# CLAUDE_CONFIG_DIR is BLANKED, not inherited: a session already running under an account root
# (this very feature) exports it, and an inherited root becomes a real "primary" that lets the
# auto-init SUCCEED — the reassuring answer from a contaminated instrument. Blank = unset to the
# binary (primary_config_root_in filters blank).
out=$(HOME="$W_BARE" USERPROFILE="$W_BARE" CLAUDE_CONFIG_DIR="" "$BIN" launch --id t --account broken-xyz 2>&1); lrc=$?
if [ "$lrc" -ne 0 ]; then ok "a failed auto-init fails the launch (rc=$lrc)"; else fail "a failed auto-init let the launch SUCCEED"; fi
case "$out" in
  *"alt init"*) ok "the failed-auto-init error names \`alt init\` as the repair" ;;
  *) fail "the failed-auto-init error does not name init: $out" ;;
esac

# B7. A launch with NO CREDENTIALS PROCEEDS (operator ruling 2026-08-21): Claude Code's own login
#     prompt handles a fresh root. Proven by driving it at a CLI that does not exist — reaching the
#     "not found on PATH" failure means the credentials check did not refuse first.
run_alt alt init fresh >/dev/null 2>&1
rm -f "$FAKE_HOME/.claude-spt/accounts/fresh/.credentials.json"
out=$(HOME="$W_HOME" USERPROFILE="$W_HOME" "$BIN" launch --id t --cli sptc-no-such-cli-xyz --account fresh 2>&1)
case "$out" in
  *"no credentials"*) ok "a credential-less account root emits a notice" ;;
  *) fail "no credential notice emitted: $out" ;;
esac
case "$out" in
  *"not found on PATH"*) ok "the launch PROCEEDED past the credential check (reached the spawn)" ;;
  *) fail "the launch did not proceed to the spawn — it refused on missing credentials: $out" ;;
esac

# B8. A traversal-shaped account name is refused rather than resolving outside the accounts dir.
out=$(HOME="$W_HOME" USERPROFILE="$W_HOME" "$BIN" launch --id t --account ../evil 2>&1); trc=$?
if [ "$trc" -ne 0 ]; then ok "a traversal-shaped account name is refused"; else fail "a traversal-shaped account name was accepted"; fi

# B9. The plugins STORE is shared from the primary root (functional proof), so the copied
#     settings.json can never enable a plugin the account root does not hold — the request #18
#     field defect (adapter plugin enabled-but-absent: no hooks, no skills, no perch). Enable-state
#     stays per-account because settings.json remains a COPY. [unit->REQ-ALT-PLUGINS-SURFACE]
printf 'marketplace' > "$PRIMARY/plugins/probe-store.txt"
if [ -f "$ACCT/plugins/probe-store.txt" ]; then ok "plugins/ store is shared into the account root"; else fail "plugins/ is NOT shared — settings.json can enable a plugin the root lacks (#18)"; fi
printf '%s' '{"enabledPlugins":{"probe":false}}' > "$ACCT/settings.json"
if grep -q 'probe' "$PRIMARY/settings.json" 2>/dev/null; then fail "settings.json is SHARED between roots — enable-state must stay per-account"; else ok "settings.json stays a per-account copy (enable-state does not cross roots)"; fi

printf '\n=== alt-account-roots: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"
