@cairnkeep/cli 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +20 -0
- package/containers/compose.yaml +29 -0
- package/containers/entrypoint.sh +54 -0
- package/containers/healthcheck.mjs +46 -0
- package/containers/quadlet/cairnkeep-data.volume +2 -0
- package/containers/quadlet/cairnkeep.container +32 -0
- package/containers/workspace-entrypoint.sh +29 -0
- package/docs/containers.md +193 -0
- package/docs/operating.md +14 -0
- package/docs/overlay-distributions.md +8 -0
- package/docs/privacy-and-data-flow.md +6 -0
- package/docs/releasing.md +19 -4
- package/docs/storage.md +6 -0
- package/mcp-memory-server/dist/embeddings.js +7 -2
- package/package.json +4 -2
- package/scripts/cairn-container +225 -0
- package/scripts/doctor.sh +16 -3
- package/scripts/probe-container-memory.mjs +51 -0
- package/scripts/test-container-runner.sh +87 -0
- package/scripts/test-container.sh +214 -0
- package/scripts/test-doctor.sh +62 -2
- package/templates/env.example.template +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/package.json" | head -1)
|
|
6
|
+
ENGINE=${CONTAINER_ENGINE:-podman}
|
|
7
|
+
SERVER_IMAGE=${CAIRNKEEP_CONTAINER_IMAGE:-ghcr.io/cairnkeep/cairnkeep:${VERSION:-latest}}
|
|
8
|
+
WORKSPACE_IMAGE=${CAIRNKEEP_WORKSPACE_IMAGE:-ghcr.io/cairnkeep/cairnkeep-workspace:${VERSION:-latest}}
|
|
9
|
+
|
|
10
|
+
usage() {
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
Usage:
|
|
13
|
+
cairn-container stdio [--image IMAGE] [--volume VOLUME]
|
|
14
|
+
cairn-container http --token-file FILE [--port PORT] [--name NAME]
|
|
15
|
+
[--allowed-hosts HOSTS] [--image IMAGE]
|
|
16
|
+
[--volume VOLUME] [--env-file FILE]
|
|
17
|
+
cairn-container workspace --repo PATH [--mode sandbox|shared]
|
|
18
|
+
[--image IMAGE] [--data-volume VOLUME]
|
|
19
|
+
[--workspace-volume VOLUME] [--home-volume VOLUME]
|
|
20
|
+
[--env-file FILE] [--secret NAME=FILE]... [--] [COMMAND...]
|
|
21
|
+
|
|
22
|
+
`sandbox` copies the selected repository into a named volume and leaves the
|
|
23
|
+
host checkout untouched. `shared` bind-mounts the selected repository read/write.
|
|
24
|
+
Secret files are mounted read-only and exposed through NAME_FILE variables.
|
|
25
|
+
EOF
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fail() {
|
|
29
|
+
echo "cairn-container: $*" >&2
|
|
30
|
+
exit 2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
require_engine() {
|
|
34
|
+
command -v "$ENGINE" >/dev/null 2>&1 || fail "$ENGINE is not installed"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
absolute_dir() {
|
|
38
|
+
[ -d "$1" ] || fail "not a directory: $1"
|
|
39
|
+
(cd "$1" && pwd -P)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
absolute_file() {
|
|
43
|
+
[ -f "$1" ] || fail "not a regular file: $1"
|
|
44
|
+
[ ! -L "$1" ] || fail "refusing symlink: $1"
|
|
45
|
+
dir=$(dirname "$1")
|
|
46
|
+
base=$(basename "$1")
|
|
47
|
+
printf '%s/%s\n' "$(cd "$dir" && pwd -P)" "$base"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
check_secret_mode() {
|
|
51
|
+
file=$1
|
|
52
|
+
if stat -c '%a' "$file" >/dev/null 2>&1; then
|
|
53
|
+
mode=$(stat -c '%a' "$file")
|
|
54
|
+
else
|
|
55
|
+
mode=$(stat -f '%Lp' "$file")
|
|
56
|
+
fi
|
|
57
|
+
case "$mode" in
|
|
58
|
+
400 | 600) ;;
|
|
59
|
+
*) fail "secret file must have mode 400 or 600: $file" ;;
|
|
60
|
+
esac
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
safe_name() {
|
|
64
|
+
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
podman_base_args() {
|
|
68
|
+
PODMAN_ARGS=(
|
|
69
|
+
run --rm
|
|
70
|
+
--userns=keep-id:uid=10001,gid=10001
|
|
71
|
+
--cap-drop=all
|
|
72
|
+
--security-opt=no-new-privileges
|
|
73
|
+
--read-only
|
|
74
|
+
--tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
run_stdio() {
|
|
79
|
+
image=$SERVER_IMAGE
|
|
80
|
+
volume=cairnkeep-data
|
|
81
|
+
while [ "$#" -gt 0 ]; do
|
|
82
|
+
case "$1" in
|
|
83
|
+
--image) [ "$#" -ge 2 ] || fail "--image requires a value"; image=$2; shift 2 ;;
|
|
84
|
+
--volume) [ "$#" -ge 2 ] || fail "--volume requires a value"; volume=$2; shift 2 ;;
|
|
85
|
+
-h | --help) usage; exit 0 ;;
|
|
86
|
+
*) fail "unknown stdio option: $1" ;;
|
|
87
|
+
esac
|
|
88
|
+
done
|
|
89
|
+
|
|
90
|
+
require_engine
|
|
91
|
+
podman_base_args
|
|
92
|
+
exec "$ENGINE" "${PODMAN_ARGS[@]}" -i \
|
|
93
|
+
--volume "$volume:/data:Z,U" \
|
|
94
|
+
"$image" stdio
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
run_http() {
|
|
98
|
+
image=$SERVER_IMAGE
|
|
99
|
+
volume=cairnkeep-data
|
|
100
|
+
name=cairnkeep
|
|
101
|
+
port=7801
|
|
102
|
+
token_file=
|
|
103
|
+
env_file=
|
|
104
|
+
allowed_hosts=
|
|
105
|
+
|
|
106
|
+
while [ "$#" -gt 0 ]; do
|
|
107
|
+
case "$1" in
|
|
108
|
+
--image) [ "$#" -ge 2 ] || fail "--image requires a value"; image=$2; shift 2 ;;
|
|
109
|
+
--volume) [ "$#" -ge 2 ] || fail "--volume requires a value"; volume=$2; shift 2 ;;
|
|
110
|
+
--name) [ "$#" -ge 2 ] || fail "--name requires a value"; name=$2; shift 2 ;;
|
|
111
|
+
--port) [ "$#" -ge 2 ] || fail "--port requires a value"; port=$2; shift 2 ;;
|
|
112
|
+
--token-file) [ "$#" -ge 2 ] || fail "--token-file requires a value"; token_file=$2; shift 2 ;;
|
|
113
|
+
--env-file) [ "$#" -ge 2 ] || fail "--env-file requires a value"; env_file=$2; shift 2 ;;
|
|
114
|
+
--allowed-hosts) [ "$#" -ge 2 ] || fail "--allowed-hosts requires a value"; allowed_hosts=$2; shift 2 ;;
|
|
115
|
+
-h | --help) usage; exit 0 ;;
|
|
116
|
+
*) fail "unknown http option: $1" ;;
|
|
117
|
+
esac
|
|
118
|
+
done
|
|
119
|
+
|
|
120
|
+
[ -n "$token_file" ] || fail "http mode requires --token-file"
|
|
121
|
+
token_file=$(absolute_file "$token_file")
|
|
122
|
+
check_secret_mode "$token_file"
|
|
123
|
+
case "$port" in *[!0-9]* | "") fail "invalid port: $port" ;; esac
|
|
124
|
+
|
|
125
|
+
require_engine
|
|
126
|
+
podman_base_args
|
|
127
|
+
PODMAN_ARGS+=(--detach --replace --name "$name")
|
|
128
|
+
[ -z "$env_file" ] || PODMAN_ARGS+=(--env-file "$(absolute_file "$env_file")")
|
|
129
|
+
if [ -z "$allowed_hosts" ]; then
|
|
130
|
+
allowed_hosts="localhost:${port},127.0.0.1:${port},localhost:7801,127.0.0.1:7801"
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
exec "$ENGINE" "${PODMAN_ARGS[@]}" \
|
|
134
|
+
--publish "127.0.0.1:${port}:7801" \
|
|
135
|
+
--volume "$volume:/data:Z,U" \
|
|
136
|
+
--volume "$token_file:/run/secrets/http-token:ro,Z" \
|
|
137
|
+
--env MCP_HTTP_HOST=0.0.0.0 \
|
|
138
|
+
--env MCP_HTTP_PORT=7801 \
|
|
139
|
+
--env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=$allowed_hosts" \
|
|
140
|
+
--env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
|
|
141
|
+
"$image" http
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
run_workspace() {
|
|
145
|
+
image=$WORKSPACE_IMAGE
|
|
146
|
+
mode=sandbox
|
|
147
|
+
repo=
|
|
148
|
+
data_volume=cairnkeep-data
|
|
149
|
+
home_volume=cairnkeep-home
|
|
150
|
+
workspace_volume=
|
|
151
|
+
env_file=
|
|
152
|
+
secret_specs=()
|
|
153
|
+
|
|
154
|
+
while [ "$#" -gt 0 ]; do
|
|
155
|
+
case "$1" in
|
|
156
|
+
--repo) [ "$#" -ge 2 ] || fail "--repo requires a value"; repo=$2; shift 2 ;;
|
|
157
|
+
--mode) [ "$#" -ge 2 ] || fail "--mode requires a value"; mode=$2; shift 2 ;;
|
|
158
|
+
--image) [ "$#" -ge 2 ] || fail "--image requires a value"; image=$2; shift 2 ;;
|
|
159
|
+
--data-volume) [ "$#" -ge 2 ] || fail "--data-volume requires a value"; data_volume=$2; shift 2 ;;
|
|
160
|
+
--home-volume) [ "$#" -ge 2 ] || fail "--home-volume requires a value"; home_volume=$2; shift 2 ;;
|
|
161
|
+
--workspace-volume) [ "$#" -ge 2 ] || fail "--workspace-volume requires a value"; workspace_volume=$2; shift 2 ;;
|
|
162
|
+
--env-file) [ "$#" -ge 2 ] || fail "--env-file requires a value"; env_file=$2; shift 2 ;;
|
|
163
|
+
--secret) [ "$#" -ge 2 ] || fail "--secret requires NAME=FILE"; secret_specs+=("$2"); shift 2 ;;
|
|
164
|
+
--) shift; break ;;
|
|
165
|
+
-h | --help) usage; exit 0 ;;
|
|
166
|
+
*) break ;;
|
|
167
|
+
esac
|
|
168
|
+
done
|
|
169
|
+
|
|
170
|
+
[ -n "$repo" ] || fail "workspace mode requires --repo"
|
|
171
|
+
repo=$(absolute_dir "$repo")
|
|
172
|
+
case "$mode" in sandbox | shared) ;; *) fail "--mode must be sandbox or shared" ;; esac
|
|
173
|
+
if [ -z "$workspace_volume" ]; then
|
|
174
|
+
repo_id=$(printf '%s' "$repo" | cksum | awk '{print $1}')
|
|
175
|
+
workspace_volume="cairnkeep-workspace-$(safe_name "$(basename "$repo")")-$repo_id"
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
require_engine
|
|
179
|
+
podman_base_args
|
|
180
|
+
PODMAN_ARGS+=(-i)
|
|
181
|
+
[ ! -t 0 ] || PODMAN_ARGS+=(-t)
|
|
182
|
+
PODMAN_ARGS+=(
|
|
183
|
+
--volume "$data_volume:/data:Z,U"
|
|
184
|
+
--volume "$home_volume:/home/cairn:Z,U"
|
|
185
|
+
--env "CAIRN_WORKSPACE_MODE=$mode"
|
|
186
|
+
)
|
|
187
|
+
if [ "$mode" = shared ]; then
|
|
188
|
+
PODMAN_ARGS+=(--volume "$repo:/workspace:rw,Z")
|
|
189
|
+
else
|
|
190
|
+
PODMAN_ARGS+=(
|
|
191
|
+
--volume "$repo:/source:ro,Z"
|
|
192
|
+
--volume "$workspace_volume:/workspace:Z,U"
|
|
193
|
+
)
|
|
194
|
+
fi
|
|
195
|
+
[ -z "$env_file" ] || PODMAN_ARGS+=(--env-file "$(absolute_file "$env_file")")
|
|
196
|
+
|
|
197
|
+
for spec in "${secret_specs[@]}"; do
|
|
198
|
+
name=${spec%%=*}
|
|
199
|
+
file=${spec#*=}
|
|
200
|
+
[ "$name" != "$spec" ] || fail "--secret requires NAME=FILE"
|
|
201
|
+
case "$name" in *[!A-Z0-9_]* | "") fail "invalid secret variable name: $name" ;; esac
|
|
202
|
+
file=$(absolute_file "$file")
|
|
203
|
+
check_secret_mode "$file"
|
|
204
|
+
target=$(safe_name "$name")
|
|
205
|
+
PODMAN_ARGS+=(
|
|
206
|
+
--volume "$file:/run/secrets/$target:ro,Z"
|
|
207
|
+
--env "${name}_FILE=/run/secrets/$target"
|
|
208
|
+
)
|
|
209
|
+
done
|
|
210
|
+
|
|
211
|
+
if [ "$#" -eq 0 ]; then
|
|
212
|
+
set -- bash
|
|
213
|
+
fi
|
|
214
|
+
exec "$ENGINE" "${PODMAN_ARGS[@]}" "$image" "$@"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
cmd=${1:-help}
|
|
218
|
+
[ "$#" -eq 0 ] || shift
|
|
219
|
+
case "$cmd" in
|
|
220
|
+
stdio) run_stdio "$@" ;;
|
|
221
|
+
http) run_http "$@" ;;
|
|
222
|
+
workspace) run_workspace "$@" ;;
|
|
223
|
+
help | -h | --help) usage ;;
|
|
224
|
+
*) fail "unknown command: $cmd" ;;
|
|
225
|
+
esac
|
package/scripts/doctor.sh
CHANGED
|
@@ -28,6 +28,17 @@ fail() { printf ' [FAIL] %s\n' "$1"; fails=$((fails + 1)); }
|
|
|
28
28
|
# reachable); a connect/timeout failure returns non-zero.
|
|
29
29
|
reachable() { curl -sS -m 5 -o /dev/null "$1" >/dev/null 2>&1; }
|
|
30
30
|
|
|
31
|
+
embedding_works() {
|
|
32
|
+
local payload
|
|
33
|
+
payload=$(node -e 'process.stdout.write(JSON.stringify({ model: process.argv[1], input: ["cairnkeep health check"] }))' \
|
|
34
|
+
"$CAIRN_MEMORY_EMBEDDING_MODEL") || return 1
|
|
35
|
+
curl -fsS -m 10 -o /dev/null \
|
|
36
|
+
-H "Authorization: Bearer $CAIRN_LLM_API_KEY" \
|
|
37
|
+
-H "Content-Type: application/json" \
|
|
38
|
+
--data "$payload" \
|
|
39
|
+
"${CAIRN_MEMORY_EMBEDDING_URL%/}/embeddings" >/dev/null 2>&1
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
echo "cairn doctor"
|
|
32
43
|
|
|
33
44
|
# 1. Required memory server: prove that the shipped build and dependencies can
|
|
@@ -66,10 +77,12 @@ fi
|
|
|
66
77
|
if [[ -n "${CAIRN_MEMORY_EMBEDDING_URL:-}" ]]; then
|
|
67
78
|
if ! command -v curl >/dev/null 2>&1; then
|
|
68
79
|
skip "embedding endpoint: curl not installed, cannot probe ${CAIRN_MEMORY_EMBEDDING_URL}"
|
|
69
|
-
elif
|
|
70
|
-
|
|
80
|
+
elif [[ -z "${CAIRN_LLM_API_KEY:-}" || -z "${CAIRN_MEMORY_EMBEDDING_MODEL:-}" ]]; then
|
|
81
|
+
fail "embedding configuration incomplete (CAIRN_LLM_API_KEY and CAIRN_MEMORY_EMBEDDING_MODEL are required)"
|
|
82
|
+
elif embedding_works; then
|
|
83
|
+
pass "embedding endpoint accepted model ${CAIRN_MEMORY_EMBEDDING_MODEL} (${CAIRN_MEMORY_EMBEDDING_URL})"
|
|
71
84
|
else
|
|
72
|
-
fail "embedding
|
|
85
|
+
fail "embedding request failed for model ${CAIRN_MEMORY_EMBEDDING_MODEL} (${CAIRN_MEMORY_EMBEDDING_URL})"
|
|
73
86
|
fi
|
|
74
87
|
else
|
|
75
88
|
skip "embedding endpoint (CAIRN_MEMORY_EMBEDDING_URL unset)"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
|
|
5
|
+
const [mode, rawUrl, token] = process.argv.slice(2);
|
|
6
|
+
if (!["write", "read"].includes(mode) || !rawUrl || !token) {
|
|
7
|
+
console.error("usage: probe-container-memory.mjs <write|read> URL TOKEN");
|
|
8
|
+
process.exit(2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const client = new Client({ name: "cairnkeep-container-probe", version: "1" });
|
|
12
|
+
const transport = new StreamableHTTPClientTransport(new URL(rawUrl), {
|
|
13
|
+
requestInit: {
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${token}`,
|
|
16
|
+
"X-Cairn-Project": "container-smoke",
|
|
17
|
+
"X-Cairn-Scopes": "identity,project",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await client.connect(transport);
|
|
24
|
+
if (mode === "write") {
|
|
25
|
+
const response = await client.callTool({
|
|
26
|
+
name: "memory_write",
|
|
27
|
+
arguments: {
|
|
28
|
+
scope: "identity",
|
|
29
|
+
key: "patterns/container-persistence",
|
|
30
|
+
value: "survives container replacement",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (response.isError) throw new Error("memory_write returned an error");
|
|
34
|
+
console.log("container memory write: ok");
|
|
35
|
+
} else {
|
|
36
|
+
const response = await client.callTool({
|
|
37
|
+
name: "memory_read",
|
|
38
|
+
arguments: {
|
|
39
|
+
scope: "identity",
|
|
40
|
+
key: "patterns/container-persistence",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const value = response.structuredContent?.results?.[0]?.value;
|
|
44
|
+
if (value !== "survives container replacement") {
|
|
45
|
+
throw new Error("persisted memory was not recovered");
|
|
46
|
+
}
|
|
47
|
+
console.log("container memory persistence: ok");
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
await client.close();
|
|
51
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
tmp=$(mktemp -d)
|
|
6
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
7
|
+
|
|
8
|
+
capture=$tmp/args
|
|
9
|
+
engine=$tmp/podman
|
|
10
|
+
repo=$tmp/repository
|
|
11
|
+
token=$tmp/token
|
|
12
|
+
env_file=$tmp/container.env
|
|
13
|
+
mkdir -p "$repo"
|
|
14
|
+
printf 'token\n' > "$token"
|
|
15
|
+
printf 'CAIRN_MEMORY_EMBEDDING_MODEL=example\n' > "$env_file"
|
|
16
|
+
chmod 600 "$token"
|
|
17
|
+
|
|
18
|
+
cat > "$engine" <<'EOF'
|
|
19
|
+
#!/bin/sh
|
|
20
|
+
printf '%s\n' "$@" > "$CAIRN_CONTAINER_CAPTURE"
|
|
21
|
+
EOF
|
|
22
|
+
chmod 755 "$engine"
|
|
23
|
+
|
|
24
|
+
run_launcher() {
|
|
25
|
+
CAIRN_CONTAINER_CAPTURE=$capture CONTAINER_ENGINE=$engine \
|
|
26
|
+
"$ROOT/scripts/cairn-container" "$@"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
has_arg() {
|
|
30
|
+
grep -Fqx -- "$1" "$capture" || {
|
|
31
|
+
echo "FAIL: missing container argument: $1" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lacks_arg() {
|
|
37
|
+
if grep -Fqx -- "$1" "$capture"; then
|
|
38
|
+
echo "FAIL: unexpected container argument: $1" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run_launcher stdio --image example/server:1 --volume example-data
|
|
44
|
+
has_arg --userns=keep-id:uid=10001,gid=10001
|
|
45
|
+
has_arg --read-only
|
|
46
|
+
has_arg --cap-drop=all
|
|
47
|
+
has_arg example-data:/data:Z,U
|
|
48
|
+
has_arg example/server:1
|
|
49
|
+
has_arg stdio
|
|
50
|
+
|
|
51
|
+
run_launcher http --image example/server:1 --token-file "$token" --port 8123 \
|
|
52
|
+
--allowed-hosts memory.example.com --env-file "$env_file"
|
|
53
|
+
has_arg --detach
|
|
54
|
+
has_arg 127.0.0.1:8123:7801
|
|
55
|
+
has_arg "$token:/run/secrets/http-token:ro,Z"
|
|
56
|
+
has_arg CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=memory.example.com
|
|
57
|
+
has_arg CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token
|
|
58
|
+
has_arg "$env_file"
|
|
59
|
+
|
|
60
|
+
chmod 644 "$token"
|
|
61
|
+
if run_launcher http --token-file "$token" >"$tmp/out" 2>"$tmp/err"; then
|
|
62
|
+
echo "FAIL: permissive token file was accepted" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
grep -Fq "mode 400 or 600" "$tmp/err"
|
|
66
|
+
chmod 600 "$token"
|
|
67
|
+
|
|
68
|
+
run_launcher workspace --repo "$repo" --image example/workspace:1 \
|
|
69
|
+
--workspace-volume example-workspace -- echo sandbox
|
|
70
|
+
has_arg "$repo:/source:ro,Z"
|
|
71
|
+
has_arg example-workspace:/workspace:Z,U
|
|
72
|
+
has_arg CAIRN_WORKSPACE_MODE=sandbox
|
|
73
|
+
lacks_arg "$repo:/workspace:rw,Z"
|
|
74
|
+
has_arg echo
|
|
75
|
+
has_arg sandbox
|
|
76
|
+
|
|
77
|
+
run_launcher workspace --repo "$repo" --mode shared --image example/workspace:1
|
|
78
|
+
has_arg "$repo:/workspace:rw,Z"
|
|
79
|
+
has_arg CAIRN_WORKSPACE_MODE=shared
|
|
80
|
+
lacks_arg "$repo:/source:ro,Z"
|
|
81
|
+
has_arg bash
|
|
82
|
+
|
|
83
|
+
repo_id=$(printf '%s' "$repo" | cksum | awk '{print $1}')
|
|
84
|
+
run_launcher workspace --repo "$repo" --image example/workspace:1
|
|
85
|
+
has_arg "cairnkeep-workspace-repository-$repo_id:/workspace:Z,U"
|
|
86
|
+
|
|
87
|
+
echo "PASS: container launcher security and workspace contracts"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
if [[ "${CAIRN_TEST_CONTAINERS:-0}" != 1 ]]; then
|
|
5
|
+
echo "SKIP: container integration (set CAIRN_TEST_CONTAINERS=1 to run)"
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
10
|
+
ENGINE=${CONTAINER_ENGINE:-}
|
|
11
|
+
if [[ -z "$ENGINE" ]]; then
|
|
12
|
+
if command -v podman >/dev/null 2>&1; then ENGINE=podman
|
|
13
|
+
elif command -v docker >/dev/null 2>&1; then ENGINE=docker
|
|
14
|
+
else echo "FAIL: Podman or Docker is required" >&2; exit 1
|
|
15
|
+
fi
|
|
16
|
+
fi
|
|
17
|
+
command -v "$ENGINE" >/dev/null 2>&1 || { echo "FAIL: container engine not found: $ENGINE" >&2; exit 1; }
|
|
18
|
+
|
|
19
|
+
suffix=$$
|
|
20
|
+
server_image="localhost/cairnkeep-test-server:$suffix"
|
|
21
|
+
workspace_image="localhost/cairnkeep-test-workspace:$suffix"
|
|
22
|
+
volume="cairnkeep-test-data-$suffix"
|
|
23
|
+
sandbox_volume="cairnkeep-test-workspace-$suffix"
|
|
24
|
+
conflict_volume="cairnkeep-test-conflict-$suffix"
|
|
25
|
+
name="cairnkeep-test-$suffix"
|
|
26
|
+
tmp=$(mktemp -d)
|
|
27
|
+
|
|
28
|
+
cleanup() {
|
|
29
|
+
"$ENGINE" rm -f "$name" >/dev/null 2>&1 || true
|
|
30
|
+
"$ENGINE" volume rm -f "$volume" "$sandbox_volume" "$conflict_volume" >/dev/null 2>&1 || true
|
|
31
|
+
"$ENGINE" image rm -f "$server_image" "$workspace_image" >/dev/null 2>&1 || true
|
|
32
|
+
rm -rf "$tmp"
|
|
33
|
+
}
|
|
34
|
+
trap cleanup EXIT
|
|
35
|
+
|
|
36
|
+
cd "$ROOT"
|
|
37
|
+
"$ENGINE" build --target server -t "$server_image" -f Containerfile .
|
|
38
|
+
"$ENGINE" build --target workspace -t "$workspace_image" -f Containerfile .
|
|
39
|
+
|
|
40
|
+
image_user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$server_image")
|
|
41
|
+
[[ "$image_user" == "10001:10001" ]] || {
|
|
42
|
+
echo "FAIL: server image must run as 10001:10001, got $image_user" >&2
|
|
43
|
+
exit 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
"$ENGINE" volume create "$volume" >/dev/null
|
|
47
|
+
node scripts/probe-memory-server.mjs --command \
|
|
48
|
+
"$ENGINE" run --rm -i --volume "$volume:/data" "$server_image" stdio
|
|
49
|
+
|
|
50
|
+
if "$ENGINE" run --rm "$server_image" http >/dev/null 2>&1; then
|
|
51
|
+
echo "FAIL: HTTP mode started without a token" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
token=container-smoke-token
|
|
56
|
+
printf '%s' "$token" > "$tmp/http-token"
|
|
57
|
+
# Docker bind mounts preserve the host UID; world-read is acceptable for this
|
|
58
|
+
# non-secret fixture. The real Podman launcher requires mode 400 or 600.
|
|
59
|
+
chmod 644 "$tmp/http-token"
|
|
60
|
+
port=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')
|
|
61
|
+
|
|
62
|
+
start_http() {
|
|
63
|
+
"$ENGINE" run --detach --name "$name" \
|
|
64
|
+
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
65
|
+
--tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
66
|
+
--publish "127.0.0.1:${port}:7801" \
|
|
67
|
+
--volume "$volume:/data" \
|
|
68
|
+
--volume "$tmp/http-token:/run/secrets/http-token:ro" \
|
|
69
|
+
--env MCP_HTTP_HOST=0.0.0.0 \
|
|
70
|
+
--env MCP_HTTP_PORT=7801 \
|
|
71
|
+
--env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
|
|
72
|
+
--env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
|
|
73
|
+
"$server_image" http >/dev/null
|
|
74
|
+
|
|
75
|
+
for _ in $(seq 1 50); do
|
|
76
|
+
if node scripts/probe-container-memory.mjs read "http://127.0.0.1:${port}/mcp" "$token" \
|
|
77
|
+
>"$tmp/read.out" 2>"$tmp/read.err"; then
|
|
78
|
+
return 0
|
|
79
|
+
fi
|
|
80
|
+
if ! "$ENGINE" inspect "$name" >/dev/null 2>&1; then break; fi
|
|
81
|
+
sleep 0.2
|
|
82
|
+
done
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# First boot: wait for MCP, then write the canary.
|
|
87
|
+
"$ENGINE" run --detach --name "$name" \
|
|
88
|
+
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
89
|
+
--tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
90
|
+
--publish "127.0.0.1:${port}:7801" \
|
|
91
|
+
--volume "$volume:/data" \
|
|
92
|
+
--volume "$tmp/http-token:/run/secrets/http-token:ro" \
|
|
93
|
+
--env MCP_HTTP_HOST=0.0.0.0 \
|
|
94
|
+
--env MCP_HTTP_PORT=7801 \
|
|
95
|
+
--env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
|
|
96
|
+
--env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
|
|
97
|
+
"$server_image" http >/dev/null
|
|
98
|
+
written=0
|
|
99
|
+
for _ in $(seq 1 50); do
|
|
100
|
+
if node scripts/probe-container-memory.mjs write "http://127.0.0.1:${port}/mcp" "$token" \
|
|
101
|
+
>"$tmp/write.out" 2>"$tmp/write.err"; then
|
|
102
|
+
written=1
|
|
103
|
+
break
|
|
104
|
+
fi
|
|
105
|
+
sleep 0.2
|
|
106
|
+
done
|
|
107
|
+
[[ "$written" == 1 ]] || {
|
|
108
|
+
cat "$tmp/write.err" >&2
|
|
109
|
+
echo "FAIL: container HTTP server did not become ready" >&2
|
|
110
|
+
exit 1
|
|
111
|
+
}
|
|
112
|
+
cat "$tmp/write.out"
|
|
113
|
+
"$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs
|
|
114
|
+
"$ENGINE" rm -f "$name" >/dev/null
|
|
115
|
+
|
|
116
|
+
# Replacement boot with the same volume must recover the canary.
|
|
117
|
+
start_http || {
|
|
118
|
+
cat "$tmp/read.err" >&2
|
|
119
|
+
echo "FAIL: persisted memory was not readable after replacement" >&2
|
|
120
|
+
exit 1
|
|
121
|
+
}
|
|
122
|
+
cat "$tmp/read.out"
|
|
123
|
+
"$ENGINE" rm -f "$name" >/dev/null
|
|
124
|
+
|
|
125
|
+
mkdir -p "$tmp/repo"
|
|
126
|
+
printf 'source\n' > "$tmp/repo/source.txt"
|
|
127
|
+
|
|
128
|
+
# Shared mode deliberately writes through to the selected host repository.
|
|
129
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
130
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
131
|
+
--volume "$tmp/repo:/workspace:Z" \
|
|
132
|
+
--env CAIRN_WORKSPACE_MODE=shared \
|
|
133
|
+
"$workspace_image" sh -c 'printf shared > shared.txt'
|
|
134
|
+
else
|
|
135
|
+
chmod 755 "$tmp"
|
|
136
|
+
chmod 777 "$tmp/repo"
|
|
137
|
+
chmod 666 "$tmp/repo/source.txt"
|
|
138
|
+
"$ENGINE" run --rm \
|
|
139
|
+
--volume "$tmp/repo:/workspace" \
|
|
140
|
+
--env CAIRN_WORKSPACE_MODE=shared \
|
|
141
|
+
"$workspace_image" sh -c 'printf shared > shared.txt'
|
|
142
|
+
fi
|
|
143
|
+
[[ $(cat "$tmp/repo/shared.txt") == shared ]] || {
|
|
144
|
+
echo "FAIL: shared workspace did not write through" >&2
|
|
145
|
+
exit 1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Prepare a writable named volume for the image's unprivileged runtime user.
|
|
149
|
+
"$ENGINE" volume create "$sandbox_volume" >/dev/null
|
|
150
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
151
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
152
|
+
--volume "$tmp/repo:/source:ro,Z" \
|
|
153
|
+
--volume "$sandbox_volume:/workspace:Z,U" \
|
|
154
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
155
|
+
"$workspace_image" sh -c 'printf sandbox > sandbox.txt'
|
|
156
|
+
else
|
|
157
|
+
"$ENGINE" run --rm --user root --entrypoint sh \
|
|
158
|
+
--volume "$sandbox_volume:/workspace" "$workspace_image" \
|
|
159
|
+
-c 'chown 10001:10001 /workspace'
|
|
160
|
+
"$ENGINE" run --rm \
|
|
161
|
+
--volume "$tmp/repo:/source:ro" \
|
|
162
|
+
--volume "$sandbox_volume:/workspace" \
|
|
163
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
164
|
+
"$workspace_image" sh -c 'printf sandbox > sandbox.txt'
|
|
165
|
+
fi
|
|
166
|
+
[[ ! -e "$tmp/repo/sandbox.txt" ]] || {
|
|
167
|
+
echo "FAIL: sandbox mode modified the host repository" >&2
|
|
168
|
+
exit 1
|
|
169
|
+
}
|
|
170
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
171
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
172
|
+
--volume "$tmp/repo:/source:ro,Z" \
|
|
173
|
+
--volume "$sandbox_volume:/workspace:Z,U" \
|
|
174
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
175
|
+
"$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
|
|
176
|
+
else
|
|
177
|
+
"$ENGINE" run --rm \
|
|
178
|
+
--volume "$tmp/repo:/source:ro" \
|
|
179
|
+
--volume "$sandbox_volume:/workspace" \
|
|
180
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
181
|
+
"$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# A pre-populated, unmarked volume must not be merged with repository content.
|
|
185
|
+
"$ENGINE" volume create "$conflict_volume" >/dev/null
|
|
186
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
187
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
188
|
+
--volume "$conflict_volume:/workspace:Z,U" \
|
|
189
|
+
--env CAIRN_WORKSPACE_MODE=shared \
|
|
190
|
+
"$workspace_image" sh -c 'printf unrelated > unrelated.txt'
|
|
191
|
+
else
|
|
192
|
+
"$ENGINE" run --rm --user root --entrypoint sh \
|
|
193
|
+
--volume "$conflict_volume:/workspace" "$workspace_image" \
|
|
194
|
+
-c 'printf unrelated > /workspace/unrelated.txt; chown -R 10001:10001 /workspace'
|
|
195
|
+
fi
|
|
196
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
197
|
+
conflict_mounts=(
|
|
198
|
+
--userns=keep-id:uid=10001,gid=10001
|
|
199
|
+
--volume "$tmp/repo:/source:ro,Z"
|
|
200
|
+
--volume "$conflict_volume:/workspace:Z,U"
|
|
201
|
+
)
|
|
202
|
+
else
|
|
203
|
+
conflict_mounts=(
|
|
204
|
+
--volume "$tmp/repo:/source:ro"
|
|
205
|
+
--volume "$conflict_volume:/workspace"
|
|
206
|
+
)
|
|
207
|
+
fi
|
|
208
|
+
if "$ENGINE" run --rm "${conflict_mounts[@]}" \
|
|
209
|
+
--env CAIRN_WORKSPACE_MODE=sandbox "$workspace_image" true >/dev/null 2>&1; then
|
|
210
|
+
echo "FAIL: sandbox merged into a non-empty unmarked volume" >&2
|
|
211
|
+
exit 1
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
echo "PASS: container stdio, guarded HTTP, persistence, shared, and sandbox modes"
|
package/scripts/test-doctor.sh
CHANGED
|
@@ -5,7 +5,12 @@ set -uo pipefail
|
|
|
5
5
|
|
|
6
6
|
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
7
7
|
tmp=$(mktemp -d)
|
|
8
|
-
|
|
8
|
+
server_pid=""
|
|
9
|
+
cleanup() {
|
|
10
|
+
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null || true
|
|
11
|
+
rm -rf "$tmp"
|
|
12
|
+
}
|
|
13
|
+
trap cleanup EXIT
|
|
9
14
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
10
15
|
|
|
11
16
|
doctor="$ROOT/scripts/doctor.sh"
|
|
@@ -46,4 +51,59 @@ else
|
|
|
46
51
|
echo " (curl absent — skipped the unreachable-endpoint case)"
|
|
47
52
|
fi
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
# 4. The embedding check performs a real authenticated model request.
|
|
55
|
+
if command -v curl >/dev/null 2>&1; then
|
|
56
|
+
cat > "$tmp/embedding-server.mjs" <<'EOF'
|
|
57
|
+
import { createServer } from "node:http";
|
|
58
|
+
import { writeFileSync } from "node:fs";
|
|
59
|
+
|
|
60
|
+
const server = createServer((request, response) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
63
|
+
request.on("end", () => {
|
|
64
|
+
let body = {};
|
|
65
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {}
|
|
66
|
+
const valid = request.url === "/v1/embeddings"
|
|
67
|
+
&& request.headers.authorization === "Bearer test-key"
|
|
68
|
+
&& body.model === "test-embedding";
|
|
69
|
+
response.writeHead(valid ? 200 : 404, { "Content-Type": "application/json" });
|
|
70
|
+
response.end(JSON.stringify(valid
|
|
71
|
+
? { data: [{ index: 0, embedding: [1, 0] }] }
|
|
72
|
+
: { error: "invalid request" }));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
server.listen(0, "127.0.0.1", () => {
|
|
76
|
+
writeFileSync(process.argv[2], String(server.address().port));
|
|
77
|
+
});
|
|
78
|
+
EOF
|
|
79
|
+
node "$tmp/embedding-server.mjs" "$tmp/embedding-port" &
|
|
80
|
+
server_pid=$!
|
|
81
|
+
for _ in $(seq 1 50); do [[ -s "$tmp/embedding-port" ]] && break; sleep 0.1; done
|
|
82
|
+
[[ -s "$tmp/embedding-port" ]] || fail "embedding test server did not start"
|
|
83
|
+
port=$(cat "$tmp/embedding-port")
|
|
84
|
+
|
|
85
|
+
proj3="$tmp/embedding-ok"; mkdir -p "$proj3/.ai"
|
|
86
|
+
cat > "$proj3/.ai/.env" <<EOF
|
|
87
|
+
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
88
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=test-embedding
|
|
89
|
+
CAIRN_LLM_API_KEY=test-key
|
|
90
|
+
EOF
|
|
91
|
+
( cd "$proj3" && "$doctor" ) >"$tmp/out3" 2>&1 ||
|
|
92
|
+
fail "doctor rejected a working embedding endpoint:\n$(cat "$tmp/out3")"
|
|
93
|
+
grep -q "\[PASS\] embedding endpoint accepted model test-embedding" "$tmp/out3" ||
|
|
94
|
+
fail "expected a functional embedding PASS line"
|
|
95
|
+
|
|
96
|
+
proj4="$tmp/embedding-wrong-model"; mkdir -p "$proj4/.ai"
|
|
97
|
+
cat > "$proj4/.ai/.env" <<EOF
|
|
98
|
+
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
99
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=missing-model
|
|
100
|
+
CAIRN_LLM_API_KEY=test-key
|
|
101
|
+
EOF
|
|
102
|
+
if ( cd "$proj4" && "$doctor" ) >"$tmp/out4" 2>&1; then
|
|
103
|
+
fail "doctor should reject a missing embedding model:\n$(cat "$tmp/out4")"
|
|
104
|
+
fi
|
|
105
|
+
grep -q "\[FAIL\] embedding request failed for model missing-model" "$tmp/out4" ||
|
|
106
|
+
fail "expected a functional embedding FAIL line"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
echo "PASS: cairn doctor (skip unconfigured, fail unreachable, probe embeddings)"
|