Bound shared MCP container lifecycle

This commit is contained in:
2026-07-24 13:03:09 -07:00
parent 8de9658b8c
commit 6177a995d5
17 changed files with 1351 additions and 117 deletions

View File

@@ -19,8 +19,10 @@ release_id="release-$$"
export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}"
export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data"
export CONTEXT_KIT_PROJECT_DIR="${ROOT}"
export CONTEXT_KIT_SEARXNG_PORT="$(pick_port)"
export CONTEXT_KIT_DOCS_PORT="$(pick_port)"
CONTEXT_KIT_SEARXNG_PORT="$(pick_port)"
CONTEXT_KIT_WEB_SEARCH_PORT="$(pick_port)"
CONTEXT_KIT_DOCS_PORT="$(pick_port)"
export CONTEXT_KIT_SEARXNG_PORT CONTEXT_KIT_WEB_SEARCH_PORT CONTEXT_KIT_DOCS_PORT
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources"
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769"
local_sources_profile="${tmp_dir}/sources.local.txt"
@@ -95,6 +97,69 @@ if (!bing.includes("decodeBingRedirect")) process.exit(1);
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-x /opt/mcp-proxy/bin/mcp-proxy
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-r /usr/local/lib/context-kit/http-entrypoint.mjs
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-r /usr/local/lib/context-kit/mcp-probe.mjs
}
assert_hostile_requests_rejected() {
local status
status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H 'Host: attacker.example' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
[[ "${status}" == 421 ]] || {
printf 'hostile Host returned HTTP %s instead of 421\n' "${status}" >&2
return 1
}
status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
[[ "${status}" == 403 ]] || {
printf 'hostile Origin returned HTTP %s instead of 403\n' "${status}" >&2
return 1
}
}
assert_web_search_backend_supervision() {
local container_id before after attempt
container_id="$(docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml ps -q web-search-mcp)"
before="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
docker exec "${container_id}" sh -eu -c '
for command_path in /proc/[0-9]*/cmdline; do
command="$(tr "\000" " " < "${command_path}")"
case "${command}" in
*node*mcp-web-search*)
pid="${command_path#/proc/}"
pid="${pid%/cmdline}"
kill -KILL "${pid}"
exit 0
;;
esac
done
exit 1
'
for ((attempt=1; attempt <= 60; attempt++)); do
after="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
if [[ "${after}" -gt "${before}" ]] && node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
printf 'web-search container did not restart after backend death\n' >&2
return 1
}
git diff --check HEAD
@@ -107,20 +172,27 @@ git ls-files --cached --error-unmatch \
scripts/smoke-web-search.mjs \
scripts/smoke-docs.mjs \
scripts/smoke-repomix.mjs \
scripts/test-compose-upgrade.sh \
scripts/test-lifecycle.sh \
scripts/test-web-search-http.mjs \
docker/web-search/mcp-probe.mjs \
docker/web-search/http-entrypoint.mjs \
scripts/release-check >/dev/null
bash -n bin/context-kit
bash -n scripts/release-check
bash -n scripts/test-compose-upgrade.sh
bash -n scripts/test-lifecycle.sh
sh -n docker/docs/entrypoint.sh
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-web-search-http.mjs
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || {
printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2
diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true
exit 1
}
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json"
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json"
cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || {
printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2
diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true
@@ -137,6 +209,9 @@ node -e 'const fs=require("node:fs"); for (const file of process.argv.slice(1))
"${tmp_dir}/claude-absolute.json"
bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json"
assert_redaction_check_does_not_disclose_matches
bash scripts/test-compose-upgrade.sh
bash scripts/test-lifecycle.sh
node scripts/test-web-search-http.mjs
bin/context-kit redaction-check
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
@@ -147,10 +222,13 @@ fi
CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null
bin/context-kit build
assert_web_search_image
bin/context-kit restart
bin/context-kit start
bin/context-kit doctor
node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp"
assert_hostile_requests_rejected
node scripts/smoke-web-search.mjs bin/context-kit web-search
node scripts/smoke-docs.mjs bin/context-kit docs
node scripts/smoke-repomix.mjs bin/context-kit repomix
assert_web_search_backend_supervision
printf 'pass release-check\n'

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
git -C "${ROOT}" show origin/main:compose.yml > "${tmp_dir}/origin-compose.yml"
CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" docker compose \
--project-directory "${ROOT}" \
--env-file /dev/null \
-p "context-kit-upgrade-check-$$" \
-f "${tmp_dir}/origin-compose.yml" \
config --format json > "${tmp_dir}/origin.json"
CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" CONTEXT_KIT_HOST_UID="$(id -u)" docker compose \
--project-directory "${ROOT}" \
--env-file /dev/null \
-p "context-kit-upgrade-check-$$" \
-f "${ROOT}/compose.yml" \
config --format json > "${tmp_dir}/current.json"
node - "${tmp_dir}/origin.json" "${tmp_dir}/current.json" <<'NODE'
const fs = require("node:fs");
const [beforePath, afterPath] = process.argv.slice(2);
const before = JSON.parse(fs.readFileSync(beforePath, "utf8"));
const after = JSON.parse(fs.readFileSync(afterPath, "utf8"));
for (const service of ["searxng", "docs-mcp"]) {
if (JSON.stringify(before.services[service]) !== JSON.stringify(after.services[service])) {
throw new Error(`${service} changed from origin/main and could be destructively recreated`);
}
}
for (const key of ["networks", "volumes"]) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
throw new Error(`${key} changed from origin/main`);
}
}
NODE
printf 'pass origin/main Compose upgrade contract\n'

380
scripts/test-lifecycle.sh Normal file
View File

@@ -0,0 +1,380 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTEXT_KIT="${ROOT}/bin/context-kit"
TEST_ROOT="$(mktemp -d)"
TEST_PROJECT="context-kit-lifecycle-$$"
LOCK_DIR="/tmp/context-kit-${TEST_PROJECT}.lock"
cleanup() {
rm -rf "${TEST_ROOT}"
if [[ -d "${LOCK_DIR}" && "$(stat -c %u "${LOCK_DIR}")" == "$(id -u)" ]]; then
rm -rf "${LOCK_DIR}"
fi
}
trap cleanup EXIT
fail_test() {
printf 'lifecycle test: %s\n' "$*" >&2
exit 1
}
fake_log() {
printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG}"
}
fake_service_for_container() {
local container_id="$1" file
for file in "${FAKE_DOCKER_STATE}"/service.*.id; do
[[ -f "${file}" ]] || continue
if [[ "$(<"${file}")" == "${container_id}" ]]; then
file="${file##*/service.}"
printf '%s' "${file%.id}"
return 0
fi
done
return 1
}
fake_compose() {
while [[ "$#" -gt 0 ]]; do
case "$1" in
-p|-f) shift 2 ;;
*) break ;;
esac
done
local command="${1:-}"
shift || true
case "${command}" in
ps)
local service="${!#}"
if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")"
fi
;;
up)
local no_recreate=0 service
local services=()
for service in "$@"; do
case "${service}" in
-d) ;;
--no-recreate) no_recreate=1 ;;
*) services+=("${service}") ;;
esac
done
if [[ -n "${FAKE_REPLACEMENT_REQUIRED:-}" && "${no_recreate}" -eq 1 && -f "${FAKE_DOCKER_STATE}/service.${FAKE_REPLACEMENT_REQUIRED}.id" ]]; then
return 17
fi
if ! mkdir "${FAKE_DOCKER_STATE}/up.guard" 2>/dev/null; then
fake_log RACE
return 75
fi
/bin/sleep 0.2
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
for service in "${services[@]}"; do
if [[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id"
fi
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
done
if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then
rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running"
fi
rmdir "${FAKE_DOCKER_STATE}/up.guard"
;;
restart)
local service
for service in "$@"; do
if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
done
if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then
rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running"
fi
[[ "${FAKE_RESTART_FAIL:-0}" -eq 0 ]]
;;
stop)
local service
for service in "$@"; do
rm -f "${FAKE_DOCKER_STATE}/service.${service}.running"
done
;;
build|version) ;;
*) fail_test "unsupported fake compose command: ${command}" ;;
esac
}
docker() {
fake_log "docker $*"
local object="${1:-}"
shift || true
case "${object}" in
info|pull) ;;
image) ;;
compose) fake_compose "$@" ;;
network|volume)
local action="${1:-}"
[[ "${action}" == inspect && -f "${FAKE_DOCKER_STATE}/${object}" ]]
;;
inspect)
local container_id="${!#}" service
service="$(fake_service_for_container "${container_id}" 2>/dev/null)" || {
[[ -f "${FAKE_DOCKER_STATE}/owner.${container_id}" ]] || return 1
if [[ "$*" == *"dev.context-kit.owner"* ]]; then
printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/owner.${container_id}")"
fi
return
}
if [[ "$*" == *".State.Running"* ]]; then
[[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] && printf 'true\n' || printf 'false\n'
elif [[ "$*" == *"com.docker.compose.project"* ]]; then
printf '%s:%s\n' "${CONTEXT_KIT_COMPOSE_PROJECT}" "${service}"
elif [[ "$*" == *".Config.User"* ]]; then
printf '%s:1000\n' "${FAKE_DOCS_UID:-$(id -u)}"
elif [[ "$*" == *"dev.context-kit.uid"* ]]; then
printf '%s\n' "${FAKE_WEB_UID:-$(id -u)}"
fi
;;
create)
local name='' owner='' argument container_id
while [[ "$#" -gt 0 ]]; do
argument="$1"
case "${argument}" in
--name) name="$2"; shift 2 ;;
--label)
[[ "$2" == dev.context-kit.owner=* ]] && owner="${2#*=}"
shift 2
;;
--network|-e|-v|--workdir|--entrypoint) shift 2 ;;
-i|--rm) shift ;;
*) shift ;;
esac
done
[[ -n "${name}" && -n "${owner}" ]] || fail_test "client container lacks a deterministic name or owner"
container_id="cid-${name}"
if [[ "${FAKE_CLIENT_OWNER_MISMATCH:-0}" -eq 1 ]]; then
printf 'unrelated-owner\n' > "${FAKE_DOCKER_STATE}/owner.${container_id}"
else
printf '%s\n' "${owner}" > "${FAKE_DOCKER_STATE}/owner.${container_id}"
fi
printf '%s\n' "${container_id}"
;;
start)
local container_id="${!#}" service
if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
;;
rm)
local container_id="${!#}" service
if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then
rm -f "${FAKE_DOCKER_STATE}/service.${service}.id" "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
rm -f "${FAKE_DOCKER_STATE}/owner.${container_id}"
;;
stop)
local container_id="${!#}" service
service="$(fake_service_for_container "${container_id}")" || return 1
rm -f "${FAKE_DOCKER_STATE}/service.${service}.running"
;;
ps)
if [[ "$*" == *"label=dev.context-kit=true"* && "${FAKE_LEGACY_CONTAINER:-0}" -eq 1 ]]; then
printf 'legacy-web-search\tCreated\t\t\n'
fi
;;
*) fail_test "unsupported fake docker command: ${object}" ;;
esac
}
curl() {
local argument url='' data=''
while [[ "$#" -gt 0 ]]; do
argument="$1"
case "${argument}" in
--data) data="$2"; shift 2 ;;
http://*|https://*) url="${argument}"; shift ;;
*) shift ;;
esac
done
case "${url}" in
*:8099/healthz) [[ "${FAKE_SEARXNG_FAIL:-0}" -eq 0 ]] ;;
*:8777/mcp)
[[ "${FAKE_WEB_SEARCH_FAIL:-0}" -eq 0 ]] || return 1
if [[ "${data}" == *'"method":"initialize"'* ]]; then
printf '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"web"}}}\n'
else
printf '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_web"},{"name":"fetch_url"}]}}\n'
fi
;;
*:8776/status) [[ "${FAKE_DOCS_FAIL:-0}" -eq 0 ]] ;;
*) fail_test "unexpected fake curl URL: ${url}" ;;
esac
}
sleep() { return 0; }
export -f fail_test fake_log fake_service_for_container fake_compose docker curl sleep
new_case() {
local name="$1"
export CASE_ROOT="${TEST_ROOT}/${name}"
export FAKE_DOCKER_STATE="${CASE_ROOT}/docker"
export FAKE_DOCKER_LOG="${CASE_ROOT}/docker.log"
export HOME="${CASE_ROOT}/home"
export CONTEXT_KIT_DATA_DIR="${CASE_ROOT}/data"
export CONTEXT_KIT_COMPOSE_PROJECT="${TEST_PROJECT}"
export CONTEXT_KIT_SEARXNG_PORT=8099
export CONTEXT_KIT_WEB_SEARCH_PORT=8777
export CONTEXT_KIT_WEB_SEARCH_HTTP_URL=http://127.0.0.1:8777/mcp
export CONTEXT_KIT_DOCS_PORT=8776
export CONTEXT_KIT_DOCS_HTTP_URL=http://127.0.0.1:8776/mcp
export CONTEXT_KIT_DOCS_SOURCES=config/sources.default.txt
unset CONTEXT_KIT_DOCKER_CIDFILE CONTEXT_KIT_RUNTIME_DIR FAKE_DOCS_UID FAKE_WEB_UID \
FAKE_REPLACEMENT_REQUIRED FAKE_RESTART_FAIL FAKE_DROP_RUNNING FAKE_SEARXNG_FAIL \
FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH
mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}"
: > "${FAKE_DOCKER_LOG}"
}
seed_service() {
local service="$1" state="${2:-running}"
printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id"
if [[ "${state}" == running ]]; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
}
new_case snippets
"${CONTEXT_KIT}" install opencode > "${CASE_ROOT}/opencode.json"
"${CONTEXT_KIT}" install claude > "${CASE_ROOT}/claude.json"
grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/opencode.json" >/dev/null || fail_test "OpenCode web search is not remote HTTP"
grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/claude.json" >/dev/null || fail_test "Claude web search is not HTTP"
new_case stdio-bridge
touch "${FAKE_DOCKER_STATE}/network"
seed_service web-search-mcp
"${CONTEXT_KIT}" web-search </dev/null
grep -E 'docker create .*dev.context-kit.lifecycle=client .*--entrypoint mcp-proxy .*http://web-search-mcp:8000/mcp' "${FAKE_DOCKER_LOG}" >/dev/null || fail_test "stdio bridge does not reuse the shared service"
[[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "stdio bridge stopped the shared service"
if compgen -G "${FAKE_DOCKER_STATE}/owner.*" >/dev/null; then
fail_test "stdio bridge did not clean up its own container"
fi
new_case client-owner-isolation
touch "${FAKE_DOCKER_STATE}/network"
seed_service web-search-mcp
export FAKE_CLIENT_OWNER_MISMATCH=1
"${CONTEXT_KIT}" web-search </dev/null
compgen -G "${FAKE_DOCKER_STATE}/owner.*" >/dev/null \
|| fail_test "stdio bridge removed a container whose owner label did not match"
grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null \
&& fail_test "stdio bridge attempted to remove a container whose owner label did not match"
new_case differing-environments
mkdir -p "${CASE_ROOT}/runtime-a" "${CASE_ROOT}/runtime-b" "${CASE_ROOT}/tmp-a" "${CASE_ROOT}/tmp-b"
XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-a" TMPDIR="${CASE_ROOT}/tmp-a" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-a.out" 2>&1 &
first_pid=$!
XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-b" TMPDIR="${CASE_ROOT}/tmp-b" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-b.out" 2>&1 &
second_pid=$!
wait "${first_pid}" || fail_test "first concurrent start failed"
wait "${second_pid}" || fail_test "second concurrent start failed"
grep -F RACE "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "environment-specific locks allowed a startup race"
[[ -f "${LOCK_DIR}/lifecycle" ]] || fail_test "canonical project lock was not used"
new_case unsafe-lock-mode
chmod 755 "${LOCK_DIR}"
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "unsafe lock mode unexpectedly succeeded"
fi
grep -F 'expected uid' "${CASE_ROOT}/start.out" >/dev/null || fail_test "unsafe lock rejection was not explicit"
grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "unsafe lock rejection happened after Compose startup"
chmod 700 "${LOCK_DIR}"
new_case origin-upgrade
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
seed_service searxng
seed_service docs-mcp
"${CONTEXT_KIT}" start
[[ "$(<"${FAKE_DOCKER_STATE}/service.searxng.id")" == cid-searxng ]] || fail_test "origin searxng was replaced"
[[ "$(<"${FAKE_DOCKER_STATE}/service.docs-mcp.id")" == cid-docs-mcp ]] || fail_test "origin docs-mcp was replaced"
[[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "upgrade did not create shared web search"
grep -F 'up -d --no-recreate searxng web-search-mcp docs-mcp' "${FAKE_DOCKER_LOG}" >/dev/null || fail_test "upgrade omitted --no-recreate"
grep -E 'docker (network|volume) rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "upgrade removed an origin resource"
new_case replacement-required
seed_service searxng
seed_service web-search-mcp
seed_service docs-mcp
export FAKE_REPLACEMENT_REQUIRED=docs-mcp
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "replacement-required start unexpectedly succeeded"
fi
for service in searxng web-search-mcp docs-mcp; do
[[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] || fail_test "replacement failure left ${service} down"
[[ "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")" == "cid-${service}" ]] || fail_test "replacement failure changed ${service}"
done
new_case readiness-failure
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
seed_service searxng
seed_service docs-mcp stopped
export FAKE_DROP_RUNNING=searxng
export FAKE_WEB_SEARCH_FAIL=1
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "readiness failure unexpectedly succeeded"
fi
[[ -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "readiness rollback did not restart prior searxng"
[[ ! -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "readiness rollback did not restore prior stopped docs state"
[[ ! -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.id" ]] || fail_test "readiness rollback left its new web container"
[[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "readiness rollback removed origin resources"
new_case restart-sources
seed_service searxng
seed_service web-search-mcp
seed_service docs-mcp
printf 'https://example.test/llms.txt\n' > "${CASE_ROOT}/sources.txt"
export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/sources.txt"
"${CONTEXT_KIT}" restart
grep -F 'https://example.test/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|| fail_test "restart did not regenerate the bind-mounted docs source list"
new_case restart-failure
seed_service searxng stopped
seed_service web-search-mcp
seed_service docs-mcp
export FAKE_DROP_RUNNING=docs-mcp
export FAKE_RESTART_FAIL=1
if "${CONTEXT_KIT}" restart >"${CASE_ROOT}/restart.out" 2>&1; then
fail_test "restart failure unexpectedly succeeded"
fi
[[ -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "restart rollback left prior docs down"
[[ ! -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "restart rollback did not restore prior stopped SearXNG state"
grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "restart rollback removed a shared container"
new_case cross-user
seed_service docs-mcp
export FAKE_DOCS_UID="$(( $(id -u) + 1 ))"
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "cross-user ownership unexpectedly succeeded"
fi
grep -F 'cross-user ownership is unsupported' "${CASE_ROOT}/start.out" >/dev/null || fail_test "cross-user rejection was not explicit"
grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "cross-user rejection happened after Compose startup"
new_case bounded-failure
export FAKE_WEB_SEARCH_FAIL=1
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "fresh readiness failure unexpectedly succeeded"
fi
for service in searxng web-search-mcp docs-mcp; do
[[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]] || fail_test "fresh failure left ${service}"
done
[[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "bounded shared resources were destructively removed"
new_case legacy-status
export FAKE_LEGACY_CONTAINER=1
"${CONTEXT_KIT}" status >"${CASE_ROOT}/status.out"
grep -F 'Legacy unlabeled Context Kit containers' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted legacy diagnostics"
grep -F 'legacy-web-search' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted the legacy container"
printf 'pass lifecycle and origin-upgrade tests\n'

View File

@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import http from "node:http";
import { once } from "node:events";
import {
createSecureMcpServer,
mcpProxyArguments,
superviseBackend
} from "../docker/web-search/http-entrypoint.mjs";
import { probeMcp } from "../docker/web-search/mcp-probe.mjs";
let backendAlive = true;
assert(mcpProxyArguments.includes("--stateless"));
const backend = http.createServer(async (request, response) => {
if (request.url === "/status") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end('{"server_instances":{"default":"configured"}}');
return;
}
if (request.method !== "POST" || request.url !== "/mcp") {
response.writeHead(404).end();
return;
}
let body = "";
for await (const chunk of request) body += chunk;
const message = JSON.parse(body);
if (message.method === "initialize") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "mock-web-search", version: "1" }
}
}));
return;
}
if (message.method === "tools/list" && backendAlive) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { tools: [{ name: "search_web" }, { name: "fetch_url" }] }
}));
return;
}
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("backend dead");
});
backend.listen(0, "127.0.0.1");
await once(backend, "listening");
const backendPort = backend.address().port;
const upstream = `http://127.0.0.1:${backendPort}`;
const front = createSecureMcpServer({ upstream });
front.listen(0, "127.0.0.1");
await once(front, "listening");
const frontPort = front.address().port;
function rawRequest({ host, origin }) {
return new Promise((resolve, reject) => {
const headers = {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
Host: host
};
if (origin !== undefined) headers.Origin = origin;
const request = http.request({
hostname: "127.0.0.1",
port: frontPort,
path: "/mcp",
method: "POST",
headers
}, response => {
response.resume();
response.once("end", () => resolve(response.statusCode));
});
request.once("error", reject);
request.end('{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}');
});
}
const tools = await probeMcp(`http://127.0.0.1:${frontPort}/mcp`);
assert.deepEqual(tools, ["fetch_url", "search_web"]);
assert.equal(await rawRequest({ host: "attacker.example" }), 421);
assert.equal(await rawRequest({ host: `127.0.0.1:${frontPort}`, origin: "https://attacker.example" }), 403);
assert.equal(await rawRequest({ host: `127.0.0.1:${frontPort}`, origin: `http://127.0.0.1:${frontPort}` }), 403);
backendAlive = false;
assert.equal((await fetch(`${upstream}/status`)).status, 200);
assert.equal((await fetch(`http://127.0.0.1:${frontPort}/healthz`)).status, 503);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("backend supervisor did not detect death")), 1000);
const stop = superviseBackend({
probe: () => probeMcp(`${upstream}/mcp`, { timeoutMs: 100 }),
intervalMs: 10,
onFailure: () => {
clearTimeout(timeout);
stop();
resolve();
}
});
});
front.close();
front.closeAllConnections();
backend.close();
backend.closeAllConnections();
console.log("pass web-search HTTP security and supervision tests");