Bound shared MCP container lifecycle
This commit is contained in:
@@ -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'
|
||||
|
||||
39
scripts/test-compose-upgrade.sh
Normal file
39
scripts/test-compose-upgrade.sh
Normal 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
380
scripts/test-lifecycle.sh
Normal 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'
|
||||
113
scripts/test-web-search-http.mjs
Normal file
113
scripts/test-web-search-http.mjs
Normal 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");
|
||||
Reference in New Issue
Block a user