Warden plan
tier: needs-server requires: [needs-postgres]
Issue #198 retires the legacy MCP passcode credential from every surface a member or admin can see or click, WITHOUT touching server-side passcode support (the ?token=&passcode= shared-URL connection stays live per server/src/modules/users/routes.ts, and every /org/**/passcode* HTTP route stays reachable — #198 is UI-only). This plan proves both halves: the UI surfaces named in the issue are gone (admin member-detail panel, invite result, Profile, Connections, the two hooks), and the server-side passcode path this issue deliberately leaves alone still answers.
$APP_URL (default http://localhost:8080) proxying to the Hono server on $SERVER_URL (default http://localhost:3000). Start via bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh — never by hand.bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh (idempotent): sign in as andrew@robin.ai / robin2026, a Super Admin, with at least one other member in the roster who has a passcode set (hasPasscode = true) — needed so the member-detail drawer has something to NOT show.curl, jq, psql on PATH; npx agent-browser (0.26.x) for the rendered checks. Env file provisioned by .warden/run.sh the same way as plan 10.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# useMyPasscode.ts + useRotatePasscode are deleted, and nothing imports them.
[ -f app/src/hooks/useMyPasscode.ts ] \
&& warden_fail "app/src/hooks/useMyPasscode.ts still exists — issue #198 asks for deletion" \
|| warden_pass "app/src/hooks/useMyPasscode.ts is deleted"
# useMyPasscode.test-d.ts is exempt: it is the PR's deliberate compile-time
# reintroduction guard — its sole import carries @ts-expect-error and MUST
# fail to resolve (tsc fails if the hook file is ever re-added). It is not a
# live importer.
IMPORTERS=$(grep -rln "useMyPasscode\|useRotatePasscode" app/src 2>/dev/null \
| grep -v 'app/src/hooks/useMyPasscode\.test-d\.ts$' | tr '\n' ' ')
[ -z "$IMPORTERS" ] \
&& warden_pass "zero live importers of useMyPasscode/useRotatePasscode in app/src" \
|| warden_fail "useMyPasscode/useRotatePasscode still imported by: $IMPORTERS"
# admin/members/page.tsx: passcode state, handlers, and the reveal/copy/reset/
# revoke panel are gone.
MEMBERS_PAGE="app/src/app/(shell)/admin/members/page.tsx"
PASSCODE_HITS=$(grep -inE "passcode|Passcode" "$MEMBERS_PAGE" 2>/dev/null | wc -l | tr -d ' ')
[ "$PASSCODE_HITS" = "0" ] \
&& warden_pass "$MEMBERS_PAGE carries zero passcode references" \
|| { grep -inE "passcode|Passcode" "$MEMBERS_PAGE" | head -5; warden_fail "$MEMBERS_PAGE still carries $PASSCODE_HITS passcode reference(s) — see above"; }
for handler in handleTogglePasscodeReveal handleCopyPasscode handleResetPasscode handleRevokePasscode; do
grep -qE "\b$handler\b" "$MEMBERS_PAGE" 2>/dev/null \
&& warden_fail "$handler still present in $MEMBERS_PAGE" \
|| warden_pass "$handler is gone from $MEMBERS_PAGE"
done
# useOrgMembers.ts: hasPasscode / passcode / passcodeLastUsedAt fields gone.
OOM="app/src/hooks/useOrgMembers.ts"
OOM_HITS=$(grep -inE "passcode|Passcode" "$OOM" 2>/dev/null | wc -l | tr -d ' ')
[ "$OOM_HITS" = "0" ] \
&& warden_pass "$OOM carries zero passcode fields" \
|| { grep -inE "passcode|Passcode" "$OOM"; warden_fail "$OOM still carries $OOM_HITS passcode field reference(s) — see above"; }
# ProfileTab.tsx: no passcode display/management.
PROFILE_TAB="app/src/components/screens/settings/ProfileTab.tsx"
PROFILE_HITS=$(grep -inE "passcode|Passcode" "$PROFILE_TAB" 2>/dev/null | wc -l | tr -d ' ')
[ "$PROFILE_HITS" = "0" ] \
&& warden_pass "$PROFILE_TAB carries zero passcode references" \
|| { grep -inE "passcode|Passcode" "$PROFILE_TAB"; warden_fail "$PROFILE_TAB still carries $PROFILE_HITS passcode reference(s) — see above"; }
# The MCP connection surface (McpConnect.tsx, rendered on /settings/connections
# — the issue's "ConnectionTab.tsx" no longer exists under that name, see plan
# notes) stays free of passcode.
CONN="app/src/components/screens/settings/McpConnect.tsx"
if [ -f "$CONN" ]; then
CONN_HITS=$(grep -inE "passcode|Passcode" "$CONN" 2>/dev/null | wc -l | tr -d ' ')
[ "$CONN_HITS" = "0" ] \
&& warden_pass "$CONN carries zero passcode references" \
|| { grep -inE "passcode|Passcode" "$CONN"; warden_fail "$CONN still carries $CONN_HITS passcode reference(s) — see above"; }
else
warden_fail "expected MCP connection component $CONN not found — did it move again?"
fi
# Copy: the three phrasings named in the issue are gone from the members page.
for phrase in "link and passcode" "join link and passcode" "share the link and passcode"; do
grep -qi "$phrase" "$MEMBERS_PAGE" 2>/dev/null \
&& warden_fail "stale copy '$phrase' still present in $MEMBERS_PAGE" \
|| warden_pass "copy '$phrase' is gone from $MEMBERS_PAGE"
done
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# Issue #198 asks to delete the "Show MCP endpoint URL to members" row and its
# mcpSettings toggle wiring — membersCanSeeMcpUrl/toggleVisibility should have
# zero callers anywhere in app/src (the hook's query/mutation plumbing may
# still exist server-side; #198 is a UI-only ask).
CALLERS=$(grep -rln "toggleVisibility\|membersCanSeeMcpUrl" app/src --include='*.tsx' 2>/dev/null | tr '\n' ' ')
[ -z "$CALLERS" ] \
&& warden_pass "zero .tsx callers of toggleVisibility/membersCanSeeMcpUrl (the settings row is unwired)" \
|| warden_fail "toggleVisibility/membersCanSeeMcpUrl still called from: $CALLERS"
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
ADMIN_EMAIL="${WARDEN_ADMIN_EMAIL:-andrew@robin.ai}"
ADMIN_PASSWORD="${WARDEN_ADMIN_PASSWORD:-robin2026}"
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered passcode-free admin/members UI" "npx agent-browser is unavailable on this box"
else
wb_count_eq0() {
local n
n="$(npx agent-browser get count "$1" 2>/dev/null | tr -dc '0-9')"
[ "${n:-0}" = "0" ]
}
wb_count_gt0() {
local n
n="$(npx agent-browser get count "$1" 2>/dev/null | tr -dc '0-9')"
[ "${n:-0}" -ge 1 ]
}
npx agent-browser cookies clear >/dev/null 2>&1
npx agent-browser open "$APP_BASE/login" >/dev/null
npx agent-browser wait "input" >/dev/null
npx agent-browser fill "input[type='email'], input[name='email']" "$ADMIN_EMAIL" >/dev/null
npx agent-browser fill "input[type='password'], input[name='password']" "$ADMIN_PASSWORD" >/dev/null
npx agent-browser click "button[type='submit']" >/dev/null
LOGGED_IN=0
for _ in $(seq 1 20); do
case "$(npx agent-browser get url 2>/dev/null)" in
''|*'/login'*) sleep 1 ;;
*) LOGGED_IN=1; break ;;
esac
done
[ "$LOGGED_IN" = "1" ] \
&& warden_pass "admin logs in through the real /login form" \
|| warden_fail "admin login did not complete — still on $(npx agent-browser get url 2>/dev/null)"
# Members list — open the first non-self member row to raise the detail drawer.
npx agent-browser open "$APP_BASE/admin/members" >/dev/null
npx agent-browser wait "table" >/dev/null 2>&1
npx agent-browser click "table tbody tr" >/dev/null 2>&1
# Negative: no passcode label, no Reveal/Copy/Reset/Revoke passcode controls.
wb_count_eq0 "text=/Passcode/i" \
&& warden_pass "member detail drawer renders with zero 'Passcode' text" \
|| warden_fail "member detail drawer still shows 'Passcode' text — panel not removed"
for label in "Reveal passcode" "Copy passcode" "Reset passcode" "Revoke passcode"; do
wb_count_eq0 "text=/$label/i" \
&& warden_pass "no '$label' control in the member detail drawer" \
|| warden_fail "'$label' control still present in the member detail drawer"
done
# Invite flow — open the invite dialog, submit, and check the result panel.
# Selectors (run 20260819T025944 postmortem): text=/Invite/i was ambiguous
# (the "Pending invitations" heading also matches) and the submit button is
# type="button" labeled "Send invite" — button[type='submit'] matched
# nothing, so the invite was never sent. Target the actual controls.
npx agent-browser click "button:has-text('Invite'):not(:has-text('invitation'))" >/dev/null 2>&1
npx agent-browser wait "#invite-email" >/dev/null 2>&1
INVITE_EMAIL="warden-198-$(date +%s)@robin.test"
npx agent-browser fill "#invite-email" "$INVITE_EMAIL" >/dev/null 2>&1
npx agent-browser click "button:has-text('Send invite')" >/dev/null 2>&1
npx agent-browser wait "text=/join link/i" >/dev/null 2>&1
wb_count_eq0 "text=/Passcode/i" \
&& warden_pass "invite result panel renders with zero 'Passcode' text" \
|| warden_fail "invite result panel still shows a Passcode block"
wb_count_eq0 "text=/My Robin passcode is/i" \
&& warden_pass "the 'My Robin passcode is…' Claude hint is gone from the invite result" \
|| warden_fail "the 'My Robin passcode is…' Claude hint still renders in the invite result"
wb_count_gt0 "text=/join link/i" \
&& warden_pass "invite result still communicates a join link (link-only copy survives)" \
|| warden_fail "invite result lost the join-link copy entirely — over-deletion, not passcode-only removal"
# Cleanup: revoke the throwaway invite so this plan leaves no product debris.
curl -s -o /dev/null -X POST -H 'Content-Type: application/json' \
-d "$(printf '{"email":"%s"}' "$INVITE_EMAIL")" \
"${SERVER_URL:-http://localhost:3000}/org/invitations/revoke" >/dev/null 2>&1 || true
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered passcode-free Profile/Connections UI" "npx agent-browser is unavailable on this box"
else
wb_count_eq0() {
local n
n="$(npx agent-browser get count "$1" 2>/dev/null | tr -dc '0-9')"
[ "${n:-0}" = "0" ]
}
# Reuses step 3's authenticated browser session (same agent-browser process).
npx agent-browser open "$APP_BASE/profile" >/dev/null
npx agent-browser wait "body" >/dev/null 2>&1
wb_count_eq0 "text=/Passcode/i" \
&& warden_pass "/profile renders with zero 'Passcode' text" \
|| warden_fail "/profile still shows a Passcode section"
npx agent-browser open "$APP_BASE/settings/connections" >/dev/null
npx agent-browser wait "body" >/dev/null 2>&1
wb_count_eq0 "text=/Passcode/i" \
&& warden_pass "/settings/connections renders with zero 'Passcode' text" \
|| warden_fail "/settings/connections still shows a Passcode section"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# #198 is UI-only. The server-side passcode surfaces it deliberately leaves
# alone must still be present: the lib generator + retry-on-collision (recent
# hardening), and the /org/**/passcode* + /my-passcode* HTTP routes.
# The symbol is `export const generatePasscode = customAlphabet(…)` on canary
# (commit aa77ad26) — match either declaration form so this negative control
# doesn't fail on canary itself.
{ [ -f server/src/lib/passcode.ts ] \
&& grep -qE "export (function|const) generatePasscode" server/src/lib/passcode.ts \
&& grep -qE "retryOnUniqueViolation" server/src/lib/passcode.ts; } \
&& warden_pass "server/src/lib/passcode.ts still exports generatePasscode + retryOnUniqueViolation" \
|| warden_fail "server-side passcode generator regressed — #198 must not touch server auth"
for route in "'/my-passcode'" "'/my-passcode/rotate'" "'/members/:memberId/passcode'" "'/members/:memberId/passcode/reset'" "'/members/:memberId/passcode/revoke'"; do
grep -qE "orgRoutes\.(get|post)\($route" server/src/routes/org.ts \
&& warden_pass "server route $route is still registered on orgRoutes" \
|| warden_fail "server route $route is missing from server/src/routes/org.ts — #198 scope creep into server auth"
done
# The ?token=&passcode= shared-URL connection path is documented as staying.
grep -qE "passcode" server/src/modules/users/routes.ts \
&& warden_pass "the shared-URL '?token=&passcode=' connection path is still present in users/routes.ts" \
|| warden_fail "server-side passcode connection support disappeared — out of #198's stated scope"
Steps 1/2 are hermetic-shaped (pure grep) but the plan's tier: is needs-server because steps 3/4 drive a real browser against the live app — per .warden/TIERS.md's primary-tier rule, the most restrictive label a plan carries wins, and needs-server outranks hermetic. Step 5 is the load-bearing negative control: it is what stops a future "clean up passcode" pass from sliding server-side auth out from under #187's OAuth migration before #187 lands — #198's own text is explicit that only the UI goes.
The issue names components/screens/settings/ConnectionTab.tsx as a removal target; that file does not exist under that name in this checkout — the live MCP-connection component is McpConnect.tsx, rendered from app/src/app/(shell)/settings/connections/page.tsx, and it already carries zero passcode references. Step 1 checks the real file so a rename doesn't silently pass this plan by grepping a path nobody writes to. Similarly, the issue's "Show MCP endpoint URL to members" row in "Members → Workspace settings" does not currently render anywhere in admin/members/page.tsx (its backing hook fields membersCanSeeMcpUrl/toggleVisibility are already unused outside test mocks) — step 2 asserts the zero-caller state rather than asserting a deletion of markup that may already be gone. See the plan's notes in the warden-design handoff for what research should confirm here.