Merge remote-tracking branch 'gitea-ai/gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf'
Demo images / Build & push demo images (push) Successful in 3m14s
Demo images / Build & push demo images (push) Successful in 3m14s
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# aiui-proxy-closed.sh — S-15 deployed-surface check for 13-02-PLAN.md.
|
||||
#
|
||||
# T-13-08..T-13-12: `/aiui/api/claude/` and `/aiui/api/ollama/` used to proxy
|
||||
# to an unauthenticated Python sidecar (port 3142) holding its own API key,
|
||||
# and `/aiui/api/openrouter/` was a plain unauthenticated relay to a paid
|
||||
# third-party API — anyone who could reach the node's web port could spend
|
||||
# the owner's budget. A green `cargo test` on model_proxy.rs proves the Rust
|
||||
# handler's own logic is correct in isolation; it proves NOTHING about which
|
||||
# target nginx is actually pointed at on a deployed node, whether the old
|
||||
# sidecar is still listening, or whether the OpenRouter relay still exists.
|
||||
# This script is that proof, against a real node (13-AI-SPEC.md S-15 — "not
|
||||
# a unit test and must not be treated as one").
|
||||
#
|
||||
# Usage: ./aiui-proxy-closed.sh <node-host> [ssh-user]
|
||||
# HTTP checks always run, with NO session cookie, and must never see 200.
|
||||
# SSH-based infra checks (systemd unit gone, port 3142 dark, single key
|
||||
# ledger) run only when `sshpass` is installed AND $AIUI_TEST_SSH_PASS is
|
||||
# set in the environment — otherwise they report SKIP, not FAIL. Never
|
||||
# hardcode a password in this file (CLAUDE.md: never commit/push secrets).
|
||||
#
|
||||
# Exit 0 = every HTTP assertion passes AND every SSH assertion that ran passed.
|
||||
|
||||
set -uo pipefail
|
||||
HOST="${1:?usage: aiui-proxy-closed.sh <node-host> [ssh-user]}"
|
||||
SSH_USER="${2:-archipelago}"
|
||||
BASE="http://${HOST}"
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
say() { printf '%s\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); say " PASS: $1"; }
|
||||
bad() { FAIL=$((FAIL+1)); say " FAIL: $1"; }
|
||||
skip() { SKIP=$((SKIP+1)); say " SKIP: $1"; }
|
||||
|
||||
# $1=method $2=path $3=optional body
|
||||
status_of() {
|
||||
if [ -n "${3:-}" ]; then
|
||||
curl -s -m 10 -o /dev/null -w '%{http_code}' -X "$1" "${BASE}${2}" -d "$3" 2>/dev/null
|
||||
else
|
||||
curl -s -m 10 -o /dev/null -w '%{http_code}' -X "$1" "${BASE}${2}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# $1=label $2=observed status — closed means 401/403/404; 200 is the exposure.
|
||||
assert_closed() {
|
||||
case "$2" in
|
||||
401|403|404) ok "$1 -> $2 (closed)" ;;
|
||||
200) bad "$1 -> 200 (OPEN — an unauthenticated caller reached the backend)" ;;
|
||||
*) bad "$1 -> '$2' (unexpected — want 401/403/404, and it is not 200 either)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
say "== S-15 AIUI model-proxy closure — node ${HOST} =="
|
||||
|
||||
# 1) /aiui/api/claude/v1/messages — no session cookie must never reach Anthropic.
|
||||
S=$(status_of POST /aiui/api/claude/v1/messages '{"model":"claude-3-5-sonnet-20241022","max_tokens":1,"messages":[]}')
|
||||
assert_closed "POST /aiui/api/claude/v1/messages (no session)" "$S"
|
||||
|
||||
# 2) /aiui/api/ollama/api/tags — no session cookie must never reach local Ollama.
|
||||
S=$(status_of GET /aiui/api/ollama/api/tags)
|
||||
assert_closed "GET /aiui/api/ollama/api/tags (no session)" "$S"
|
||||
|
||||
# 3) /aiui/api/openrouter/ — must be entirely GONE, not merely gated: 404 specifically.
|
||||
S=$(status_of GET /aiui/api/openrouter/)
|
||||
if [ "$S" = "404" ]; then
|
||||
ok "GET /aiui/api/openrouter/ -> 404 (relay deleted)"
|
||||
else
|
||||
bad "GET /aiui/api/openrouter/ -> $S (want 404 — the relay must not exist at all, not just be gated)"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "-- SSH-based infra checks (${SSH_USER}@${HOST}) --"
|
||||
if ! command -v sshpass >/dev/null 2>&1 || [ -z "${AIUI_TEST_SSH_PASS:-}" ]; then
|
||||
skip "claude-api-proxy systemd unit (no sshpass or AIUI_TEST_SSH_PASS unset)"
|
||||
skip "port 3142 listener (no sshpass or AIUI_TEST_SSH_PASS unset)"
|
||||
skip "single key ledger (no sshpass or AIUI_TEST_SSH_PASS unset)"
|
||||
else
|
||||
ssh_run() {
|
||||
sshpass -p "${AIUI_TEST_SSH_PASS}" ssh -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
||||
"${SSH_USER}@${HOST}" "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
UNIT_STATE=$(ssh_run 'systemctl is-active claude-api-proxy 2>&1')
|
||||
case "$UNIT_STATE" in
|
||||
inactive|unknown) ok "claude-api-proxy unit is '$UNIT_STATE'" ;;
|
||||
*"could not be found"*) ok "claude-api-proxy unit is gone (could not be found)" ;;
|
||||
active) bad "claude-api-proxy unit is still ACTIVE — sidecar not torn down" ;;
|
||||
*) bad "claude-api-proxy unit state unexpected: '$UNIT_STATE'" ;;
|
||||
esac
|
||||
|
||||
PORT_COUNT=$(ssh_run "ss -ltn 2>/dev/null | grep -c ':3142 '")
|
||||
[ "${PORT_COUNT:-1}" = "0" ] && ok "nothing listening on :3142" || bad "port 3142 still has a listener (count=${PORT_COUNT:-?})"
|
||||
|
||||
# claude-api-key's PRESENCE depends on whether an operator has configured a
|
||||
# key on this node at all (via system.settings.set claude_api_key) — a
|
||||
# freshly provisioned/dev node with no key set is expected to have neither
|
||||
# file, and that is not a defect in this fix. The security-relevant
|
||||
# invariant this plan makes is narrower and unconditional: the SECOND
|
||||
# ledger (claude-api-proxy.env) must never exist, whether or not the first
|
||||
# one does. Presence of claude-api-key is reported for visibility only.
|
||||
LEDGER=$(ssh_run 'sudo ls /var/lib/archipelago/secrets/ 2>/dev/null')
|
||||
if grep -qx 'claude-api-key' <<<"$LEDGER"; then
|
||||
say " INFO: claude-api-key ledger present (a key is configured on this node)"
|
||||
else
|
||||
say " INFO: claude-api-key ledger absent (no key configured on this node yet — not a defect)"
|
||||
fi
|
||||
if grep -q 'claude-api-proxy.env' <<<"$LEDGER"; then
|
||||
bad "claude-api-proxy.env still present — second key ledger not deleted"
|
||||
else
|
||||
ok "claude-api-proxy.env absent (single ledger enforced)"
|
||||
fi
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== ${HOST}: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped =="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-guard-same-host.sh — regression pin for assert_safe_same_host_deploy
|
||||
# (scripts/lib/common.sh), widened in 13-09 from containment-only to any
|
||||
# resolved-path mismatch on the same host.
|
||||
#
|
||||
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
|
||||
# was INSIDE the destination mirrored the source onto the destination and
|
||||
# deleted ~1810 tracked files, a running dev server, and two sessions'
|
||||
# uncommitted work. The original fix refused only containment (source-in-
|
||||
# destination or destination-in-source). It missed SIBLING directories that
|
||||
# share a parent but neither contains the other — e.g. this session's own
|
||||
# worktree topology, archy-phase13 (source) vs archy (the main checkout,
|
||||
# TARGET_DIR's resolved symlink target) — which is the identical rsync
|
||||
# --delete hazard through a shape the old two-case guard let through.
|
||||
#
|
||||
# No SSH, no rsync, no real deploy — pure fixture strings against the
|
||||
# function. Usage: ./deploy-guard-same-host.sh (takes no host argument)
|
||||
# Exit 0 = all assertions pass.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
# shellcheck source=../../scripts/lib/common.sh
|
||||
source "$PROJECT_DIR/scripts/lib/common.sh"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
say() { printf '%s\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); say " PASS: $1"; }
|
||||
bad() { FAIL=$((FAIL+1)); say " FAIL: $1"; }
|
||||
|
||||
# Helper: run assert_safe_same_host_deploy and check its exit code against
|
||||
# an expectation ("allow" or "refuse"), silencing its stderr message so the
|
||||
# test output stays readable.
|
||||
check() {
|
||||
local desc="$1" src="$2" dst="$3" expect="$4"
|
||||
local rc
|
||||
assert_safe_same_host_deploy "$src" "$dst" >/dev/null 2>&1
|
||||
rc=$?
|
||||
if [ "$expect" = "allow" ]; then
|
||||
[ "$rc" -eq 0 ] && ok "$desc" || bad "$desc (expected allow/exit 0, got exit $rc)"
|
||||
else
|
||||
[ "$rc" -ne 0 ] && ok "$desc" || bad "$desc (expected refuse/non-zero, got exit $rc)"
|
||||
fi
|
||||
}
|
||||
|
||||
say "== assert_safe_same_host_deploy — fixture matrix =="
|
||||
|
||||
# 1) Identical resolved source and destination: allowed. The normal
|
||||
# in-place deploy from the main checkout onto its own symlinked
|
||||
# destination.
|
||||
check "identical resolved paths are allowed" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"allow"
|
||||
|
||||
# 2) Source is inside (a subdirectory of) the destination: refused. The
|
||||
# original 2026-07-31 containment case.
|
||||
check "source-inside-destination is refused" \
|
||||
"/home/archipelago/Projects/archy/.claude/worktrees/some-agent" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"refuse"
|
||||
|
||||
# 3) Destination is inside the source: refused. The mirror-image
|
||||
# containment case.
|
||||
check "destination-inside-source is refused" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"/home/archipelago/Projects/archy/.claude/worktrees/some-agent" \
|
||||
"refuse"
|
||||
|
||||
# 4) Sibling-directory regression pin — the exact shape this session's own
|
||||
# worktree topology exhibits, and the gap the old two-case guard let
|
||||
# through: archy-phase13 (this worktree) as source, archy (the main
|
||||
# checkout, TARGET_DIR's resolved symlink target) as destination. Share
|
||||
# a parent (/home/archipelago/Projects); neither contains the other.
|
||||
check "sibling directories (archy-phase13 vs archy) are refused [SIBLING REGRESSION PIN]" \
|
||||
"/home/archipelago/Projects/archy-phase13" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"refuse"
|
||||
|
||||
# 5) Two completely unrelated same-host paths with no shared parent at
|
||||
# all: refused. Same-host plus any mismatch is refused, not just the
|
||||
# two containment shapes.
|
||||
check "unrelated paths with no shared parent are refused" \
|
||||
"/home/archipelago/Projects/archy" \
|
||||
"/opt/archipelago/web-ui" \
|
||||
"refuse"
|
||||
|
||||
# 6) A refused case names both resolved paths and the 2026-07-31 incident
|
||||
# on stderr, so a future operator understands why rather than
|
||||
# reflexively retrying with a force flag.
|
||||
MSG="$(assert_safe_same_host_deploy "/home/archipelago/Projects/archy-phase13" "/home/archipelago/Projects/archy" 2>&1 >/dev/null)"
|
||||
if echo "$MSG" | grep -q '/home/archipelago/Projects/archy-phase13' \
|
||||
&& echo "$MSG" | grep -q '/home/archipelago/Projects/archy' \
|
||||
&& echo "$MSG" | grep -q '2026-07-31'; then
|
||||
ok "refusal message names both resolved paths and the 2026-07-31 incident"
|
||||
else
|
||||
bad "refusal message missing an expected component: $MSG"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== ${PASS} passed, ${FAIL} failed =="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user