@cairnkeep/cli 2.0.0 → 2.2.0

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,83 @@
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
+ echo "PASS: container launcher security and workspace contracts"
@@ -0,0 +1,183 @@
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
+ name="cairnkeep-test-$suffix"
25
+ tmp=$(mktemp -d)
26
+
27
+ cleanup() {
28
+ "$ENGINE" rm -f "$name" >/dev/null 2>&1 || true
29
+ "$ENGINE" volume rm -f "$volume" "$sandbox_volume" >/dev/null 2>&1 || true
30
+ "$ENGINE" image rm -f "$server_image" "$workspace_image" >/dev/null 2>&1 || true
31
+ rm -rf "$tmp"
32
+ }
33
+ trap cleanup EXIT
34
+
35
+ cd "$ROOT"
36
+ "$ENGINE" build --target server -t "$server_image" -f Containerfile .
37
+ "$ENGINE" build --target workspace -t "$workspace_image" -f Containerfile .
38
+
39
+ image_user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$server_image")
40
+ [[ "$image_user" == "10001:10001" ]] || {
41
+ echo "FAIL: server image must run as 10001:10001, got $image_user" >&2
42
+ exit 1
43
+ }
44
+
45
+ "$ENGINE" volume create "$volume" >/dev/null
46
+ node scripts/probe-memory-server.mjs --command \
47
+ "$ENGINE" run --rm -i --volume "$volume:/data" "$server_image" stdio
48
+
49
+ if "$ENGINE" run --rm "$server_image" http >/dev/null 2>&1; then
50
+ echo "FAIL: HTTP mode started without a token" >&2
51
+ exit 1
52
+ fi
53
+
54
+ token=container-smoke-token
55
+ printf '%s' "$token" > "$tmp/http-token"
56
+ # Docker bind mounts preserve the host UID; world-read is acceptable for this
57
+ # non-secret fixture. The real Podman launcher requires mode 400 or 600.
58
+ chmod 644 "$tmp/http-token"
59
+ port=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')
60
+
61
+ start_http() {
62
+ "$ENGINE" run --detach --name "$name" \
63
+ --read-only --cap-drop=all --security-opt=no-new-privileges \
64
+ --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
65
+ --publish "127.0.0.1:${port}:7801" \
66
+ --volume "$volume:/data" \
67
+ --volume "$tmp/http-token:/run/secrets/http-token:ro" \
68
+ --env MCP_HTTP_HOST=0.0.0.0 \
69
+ --env MCP_HTTP_PORT=7801 \
70
+ --env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
71
+ --env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
72
+ "$server_image" http >/dev/null
73
+
74
+ for _ in $(seq 1 50); do
75
+ if node scripts/probe-container-memory.mjs read "http://127.0.0.1:${port}/mcp" "$token" \
76
+ >"$tmp/read.out" 2>"$tmp/read.err"; then
77
+ return 0
78
+ fi
79
+ if ! "$ENGINE" inspect "$name" >/dev/null 2>&1; then break; fi
80
+ sleep 0.2
81
+ done
82
+ return 1
83
+ }
84
+
85
+ # First boot: wait for MCP, then write the canary.
86
+ "$ENGINE" run --detach --name "$name" \
87
+ --read-only --cap-drop=all --security-opt=no-new-privileges \
88
+ --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
89
+ --publish "127.0.0.1:${port}:7801" \
90
+ --volume "$volume:/data" \
91
+ --volume "$tmp/http-token:/run/secrets/http-token:ro" \
92
+ --env MCP_HTTP_HOST=0.0.0.0 \
93
+ --env MCP_HTTP_PORT=7801 \
94
+ --env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
95
+ --env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
96
+ "$server_image" http >/dev/null
97
+ written=0
98
+ for _ in $(seq 1 50); do
99
+ if node scripts/probe-container-memory.mjs write "http://127.0.0.1:${port}/mcp" "$token" \
100
+ >"$tmp/write.out" 2>"$tmp/write.err"; then
101
+ written=1
102
+ break
103
+ fi
104
+ sleep 0.2
105
+ done
106
+ [[ "$written" == 1 ]] || {
107
+ cat "$tmp/write.err" >&2
108
+ echo "FAIL: container HTTP server did not become ready" >&2
109
+ exit 1
110
+ }
111
+ cat "$tmp/write.out"
112
+ "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs
113
+ "$ENGINE" rm -f "$name" >/dev/null
114
+
115
+ # Replacement boot with the same volume must recover the canary.
116
+ start_http || {
117
+ cat "$tmp/read.err" >&2
118
+ echo "FAIL: persisted memory was not readable after replacement" >&2
119
+ exit 1
120
+ }
121
+ cat "$tmp/read.out"
122
+ "$ENGINE" rm -f "$name" >/dev/null
123
+
124
+ mkdir -p "$tmp/repo"
125
+ printf 'source\n' > "$tmp/repo/source.txt"
126
+
127
+ # Shared mode deliberately writes through to the selected host repository.
128
+ if [[ "$ENGINE" == podman ]]; then
129
+ "$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
130
+ --volume "$tmp/repo:/workspace:Z" \
131
+ --env CAIRN_WORKSPACE_MODE=shared \
132
+ "$workspace_image" sh -c 'printf shared > shared.txt'
133
+ else
134
+ chmod 755 "$tmp"
135
+ chmod 777 "$tmp/repo"
136
+ chmod 666 "$tmp/repo/source.txt"
137
+ "$ENGINE" run --rm \
138
+ --volume "$tmp/repo:/workspace" \
139
+ --env CAIRN_WORKSPACE_MODE=shared \
140
+ "$workspace_image" sh -c 'printf shared > shared.txt'
141
+ fi
142
+ [[ $(cat "$tmp/repo/shared.txt") == shared ]] || {
143
+ echo "FAIL: shared workspace did not write through" >&2
144
+ exit 1
145
+ }
146
+
147
+ # Prepare a writable named volume for the image's unprivileged runtime user.
148
+ "$ENGINE" volume create "$sandbox_volume" >/dev/null
149
+ if [[ "$ENGINE" == podman ]]; then
150
+ "$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
151
+ --volume "$tmp/repo:/source:ro,Z" \
152
+ --volume "$sandbox_volume:/workspace:Z,U" \
153
+ --env CAIRN_WORKSPACE_MODE=sandbox \
154
+ "$workspace_image" sh -c 'printf sandbox > sandbox.txt'
155
+ else
156
+ "$ENGINE" run --rm --user root --entrypoint sh \
157
+ --volume "$sandbox_volume:/workspace" "$workspace_image" \
158
+ -c 'chown 10001:10001 /workspace'
159
+ "$ENGINE" run --rm \
160
+ --volume "$tmp/repo:/source:ro" \
161
+ --volume "$sandbox_volume:/workspace" \
162
+ --env CAIRN_WORKSPACE_MODE=sandbox \
163
+ "$workspace_image" sh -c 'printf sandbox > sandbox.txt'
164
+ fi
165
+ [[ ! -e "$tmp/repo/sandbox.txt" ]] || {
166
+ echo "FAIL: sandbox mode modified the host repository" >&2
167
+ exit 1
168
+ }
169
+ if [[ "$ENGINE" == podman ]]; then
170
+ "$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
171
+ --volume "$tmp/repo:/source:ro,Z" \
172
+ --volume "$sandbox_volume:/workspace:Z,U" \
173
+ --env CAIRN_WORKSPACE_MODE=sandbox \
174
+ "$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
175
+ else
176
+ "$ENGINE" run --rm \
177
+ --volume "$tmp/repo:/source:ro" \
178
+ --volume "$sandbox_volume:/workspace" \
179
+ --env CAIRN_WORKSPACE_MODE=sandbox \
180
+ "$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
181
+ fi
182
+
183
+ 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)"
@@ -7,6 +7,7 @@
7
7
  # CAIRN_LLM_API_URL=https://api.openai.com/v1
8
8
  # CAIRN_LLM_EXTRACTION_MODEL=gpt-4o-mini
9
9
  # CAIRN_MEMORY_EMBEDDING_MODEL=text-embedding-3-small
10
+ # CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS=15000
10
11
  #
11
12
  # Git host for the collaboration commands (memory-sync, repo-review, security-audit).
12
13
  # One of: github | gitlab | codeberg | forgejo | none. See docs/git-providers.md.