@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.
@@ -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 reachable "$CAIRN_MEMORY_EMBEDDING_URL"; then
70
- pass "embedding endpoint reachable (${CAIRN_MEMORY_EMBEDDING_URL})"
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 endpoint unreachable (${CAIRN_MEMORY_EMBEDDING_URL})"
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"
@@ -5,7 +5,12 @@ set -uo pipefail
5
5
 
6
6
  ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
7
7
  tmp=$(mktemp -d)
8
- trap 'rm -rf "$tmp"' EXIT
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
- echo "PASS: cairn doctor (skip unconfigured, fail configured-unreachable)"
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)"