@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.
@@ -29,6 +29,8 @@ const anythingllmSyncScript = process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT
29
29
  ? resolve(expandHome(process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT))
30
30
  : join(infraRoot, "examples", "anythingllm", "sync_to_anythingllm.py");
31
31
  const HISTORY_NAMESPACE = "__history__";
32
+ const REVIEWED_NAMESPACE = "__reviewed__";
33
+ const REVIEW_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
32
34
  const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
33
35
  const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
34
36
  function expandHome(value) {
@@ -180,17 +182,52 @@ async function openScope(scope, create, options = {}) {
180
182
  function isHistoryKey(key) {
181
183
  return key === HISTORY_NAMESPACE || key.startsWith(`${HISTORY_NAMESPACE}/`);
182
184
  }
185
+ function isReviewedKey(key) {
186
+ return key === REVIEWED_NAMESPACE || key.startsWith(`${REVIEWED_NAMESPACE}/`);
187
+ }
188
+ function assertWritableMemoryKey(key) {
189
+ if (isHistoryKey(key) || isReviewedKey(key)) {
190
+ throw new Error(`Keys under ${HISTORY_NAMESPACE}/ and ${REVIEWED_NAMESPACE}/ are reserved.`);
191
+ }
192
+ }
183
193
  function historyPrefix(baseKey) {
184
194
  return `${HISTORY_NAMESPACE}/${baseKey}/`;
185
195
  }
186
196
  function historySnapshotKey(baseKey, timestamp) {
187
- return `${historyPrefix(baseKey)}${timestamp}`;
197
+ return `${historyPrefix(baseKey)}${timestamp}-${randomUUID()}`;
198
+ }
199
+ const reviewedRecordSchema = z.object({
200
+ schema_version: z.literal(1),
201
+ review_id: z.string(),
202
+ key: z.string(),
203
+ value_hash: z.string(),
204
+ state: z.enum(["active", "superseded", "invalidated"]),
205
+ applied_at: z.string(),
206
+ superseded_at: z.string().optional(),
207
+ superseded_by: z.string().optional(),
208
+ invalidated_at: z.string().optional(),
209
+ invalidation_reason: z.string().optional(),
210
+ });
211
+ function reviewedRecordKey(reviewId) {
212
+ return `${REVIEWED_NAMESPACE}/${reviewId}`;
213
+ }
214
+ function parseReviewedRecord(value) {
215
+ const parsed = reviewedRecordSchema.safeParse(value);
216
+ if (!parsed.success) {
217
+ throw new Error("Stored reviewed-memory provenance is invalid; refusing to continue.");
218
+ }
219
+ return parsed.data;
220
+ }
221
+ async function inImmediateScopeTransaction(agent, operation) {
222
+ const transaction = agent.getDatabase().transaction(operation);
223
+ const immediate = transaction.immediate;
224
+ return immediate();
188
225
  }
189
226
  function visibleEntries(entries, includeHistory) {
190
227
  if (includeHistory) {
191
228
  return entries;
192
229
  }
193
- return entries.filter((entry) => !isHistoryKey(entry.key));
230
+ return entries.filter((entry) => !isHistoryKey(entry.key) && !isReviewedKey(entry.key));
194
231
  }
195
232
  async function listEntries(scope, prefix = "", options = {}) {
196
233
  const agent = await openScope(scope, false, options);
@@ -759,9 +796,7 @@ function createMemoryServer(context = {}) {
759
796
  promote_to: z.string().optional(),
760
797
  }),
761
798
  }, async ({ scope, key, value, promote_to }) => {
762
- if (isHistoryKey(key)) {
763
- throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
764
- }
799
+ assertWritableMemoryKey(key);
765
800
  const targets = promote_to && promote_to !== scope ? [scope, promote_to] : [scope];
766
801
  // Collision-safe: in the unified store, writes from different repos/machines
767
802
  // can share a key. If a different value already exists, preserve the old one
@@ -824,6 +859,9 @@ function createMemoryServer(context = {}) {
824
859
  key: z.string().min(1),
825
860
  }),
826
861
  }, async ({ scope, key }) => {
862
+ if (isReviewedKey(key)) {
863
+ throw new Error(`Keys under ${REVIEWED_NAMESPACE}/ are reserved for reviewed-memory provenance.`);
864
+ }
827
865
  const agent = await openScope(scope, false, scopeOptions);
828
866
  if (agent) {
829
867
  try {
@@ -887,9 +925,7 @@ function createMemoryServer(context = {}) {
887
925
  reason: z.string().optional(),
888
926
  }),
889
927
  }, async ({ scope, key, value, reason }) => {
890
- if (isHistoryKey(key)) {
891
- throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
892
- }
928
+ assertWritableMemoryKey(key);
893
929
  const agent = await openScope(scope, true, scopeOptions);
894
930
  if (!agent) {
895
931
  throw new Error(`Unable to open scope ${scope}.`);
@@ -929,6 +965,207 @@ function createMemoryServer(context = {}) {
929
965
  await agent.close();
930
966
  }
931
967
  });
968
+ server.registerTool("memory_apply_reviewed", {
969
+ description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
970
+ inputSchema: z.object({
971
+ scope: z.string(),
972
+ review_id: z.string().regex(REVIEW_ID_PATTERN),
973
+ key: z.string().min(1),
974
+ value: z.string(),
975
+ }),
976
+ }, async ({ scope, review_id, key, value }) => {
977
+ assertWritableMemoryKey(key);
978
+ const agent = await openScope(scope, true, scopeOptions);
979
+ if (!agent) {
980
+ throw new Error(`Unable to open scope ${scope}.`);
981
+ }
982
+ try {
983
+ const payload = await inImmediateScopeTransaction(agent, async () => {
984
+ const recordKey = reviewedRecordKey(review_id);
985
+ const valueHash = hashText(value);
986
+ const stored = await agent.kv.get(recordKey);
987
+ if (stored !== undefined) {
988
+ const existing = parseReviewedRecord(stored);
989
+ if (existing.key !== key || existing.value_hash !== valueHash) {
990
+ throw new Error(`Review id ${review_id} was already used with different content.`);
991
+ }
992
+ if (existing.state !== "active") {
993
+ throw new Error(`Review id ${review_id} is ${existing.state} and cannot be reapplied.`);
994
+ }
995
+ return {
996
+ ok: true,
997
+ scope,
998
+ review_id,
999
+ key,
1000
+ applied: false,
1001
+ idempotent: true,
1002
+ snapshot_key: null,
1003
+ };
1004
+ }
1005
+ const appliedAt = new Date().toISOString();
1006
+ const provenance = await agent.kv.list(`${REVIEWED_NAMESPACE}/`);
1007
+ for (const entry of provenance) {
1008
+ const previousRecord = parseReviewedRecord(entry.value);
1009
+ if (previousRecord.state === "active" && previousRecord.key === key) {
1010
+ await agent.kv.set(entry.key, {
1011
+ ...previousRecord,
1012
+ state: "superseded",
1013
+ superseded_at: appliedAt,
1014
+ superseded_by: review_id,
1015
+ });
1016
+ }
1017
+ }
1018
+ const previous = await agent.kv.get(key);
1019
+ const previousValue = previous === undefined ? undefined : normalizeValue(previous);
1020
+ let snapshotKey = null;
1021
+ if (previousValue !== undefined && previousValue !== value) {
1022
+ snapshotKey = historySnapshotKey(key, appliedAt);
1023
+ await agent.kv.set(snapshotKey, {
1024
+ value: previousValue,
1025
+ superseded_at: appliedAt,
1026
+ superseded_reason: `reviewed memory ${review_id}`,
1027
+ });
1028
+ }
1029
+ await agent.kv.set(key, value);
1030
+ await agent.kv.set(recordKey, {
1031
+ schema_version: 1,
1032
+ review_id,
1033
+ key,
1034
+ value_hash: valueHash,
1035
+ state: "active",
1036
+ applied_at: appliedAt,
1037
+ });
1038
+ return {
1039
+ ok: true,
1040
+ scope,
1041
+ review_id,
1042
+ key,
1043
+ applied: true,
1044
+ idempotent: false,
1045
+ snapshot_key: snapshotKey,
1046
+ };
1047
+ });
1048
+ return {
1049
+ content: [{ type: "text", text: asToolText(payload) }],
1050
+ structuredContent: payload,
1051
+ };
1052
+ }
1053
+ finally {
1054
+ await agent.close();
1055
+ }
1056
+ });
1057
+ server.registerTool("memory_invalidate_reviewed", {
1058
+ description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
1059
+ inputSchema: z.object({
1060
+ scope: z.string(),
1061
+ review_id: z.string().regex(REVIEW_ID_PATTERN),
1062
+ key: z.string().min(1),
1063
+ reason: z.string().max(512).optional(),
1064
+ }),
1065
+ }, async ({ scope, review_id, key, reason }) => {
1066
+ assertWritableMemoryKey(key);
1067
+ const agent = await openScope(scope, true, scopeOptions);
1068
+ if (!agent) {
1069
+ throw new Error(`Unable to open scope ${scope}.`);
1070
+ }
1071
+ try {
1072
+ const payload = await inImmediateScopeTransaction(agent, async () => {
1073
+ const recordKey = reviewedRecordKey(review_id);
1074
+ const stored = await agent.kv.get(recordKey);
1075
+ if (stored === undefined) {
1076
+ const invalidatedAt = new Date().toISOString();
1077
+ await agent.kv.set(recordKey, {
1078
+ schema_version: 1,
1079
+ review_id,
1080
+ key,
1081
+ value_hash: "",
1082
+ state: "invalidated",
1083
+ applied_at: invalidatedAt,
1084
+ invalidated_at: invalidatedAt,
1085
+ ...(reason ? { invalidation_reason: reason } : {}),
1086
+ });
1087
+ return {
1088
+ ok: true,
1089
+ scope,
1090
+ review_id,
1091
+ key,
1092
+ invalidated: true,
1093
+ idempotent: false,
1094
+ missing: true,
1095
+ removed: false,
1096
+ current_changed: false,
1097
+ snapshot_key: null,
1098
+ };
1099
+ }
1100
+ const record = parseReviewedRecord(stored);
1101
+ if (record.key !== key) {
1102
+ throw new Error(`Review id ${review_id} belongs to a different memory key.`);
1103
+ }
1104
+ if (record.state === "invalidated") {
1105
+ return {
1106
+ ok: true,
1107
+ scope,
1108
+ review_id,
1109
+ key: record.key,
1110
+ invalidated: false,
1111
+ idempotent: true,
1112
+ missing: record.value_hash === "",
1113
+ removed: false,
1114
+ current_changed: false,
1115
+ snapshot_key: null,
1116
+ };
1117
+ }
1118
+ const invalidatedAt = new Date().toISOString();
1119
+ let currentChanged = false;
1120
+ let removed = false;
1121
+ let snapshotKey = null;
1122
+ if (record.state === "active") {
1123
+ const current = await agent.kv.get(record.key);
1124
+ if (current !== undefined) {
1125
+ const currentValue = normalizeValue(current);
1126
+ if (hashText(currentValue) === record.value_hash) {
1127
+ snapshotKey = historySnapshotKey(record.key, invalidatedAt);
1128
+ await agent.kv.set(snapshotKey, {
1129
+ value: currentValue,
1130
+ superseded_at: invalidatedAt,
1131
+ superseded_reason: reason ?? `reviewed memory ${review_id} invalidated`,
1132
+ });
1133
+ await agent.kv.delete(record.key);
1134
+ removed = true;
1135
+ }
1136
+ else {
1137
+ currentChanged = true;
1138
+ }
1139
+ }
1140
+ }
1141
+ await agent.kv.set(recordKey, {
1142
+ ...record,
1143
+ state: "invalidated",
1144
+ invalidated_at: invalidatedAt,
1145
+ ...(reason ? { invalidation_reason: reason } : {}),
1146
+ });
1147
+ return {
1148
+ ok: true,
1149
+ scope,
1150
+ review_id,
1151
+ key: record.key,
1152
+ invalidated: true,
1153
+ idempotent: false,
1154
+ missing: false,
1155
+ removed,
1156
+ current_changed: currentChanged,
1157
+ snapshot_key: snapshotKey,
1158
+ };
1159
+ });
1160
+ return {
1161
+ content: [{ type: "text", text: asToolText(payload) }],
1162
+ structuredContent: payload,
1163
+ };
1164
+ }
1165
+ finally {
1166
+ await agent.close();
1167
+ }
1168
+ });
932
1169
  server.registerTool("memory_history", {
933
1170
  description: "Read prior versions of a memory entry from the hidden history namespace.",
934
1171
  inputSchema: z.object({
@@ -22,8 +22,9 @@
22
22
  "check:explore-cache": "node scripts/smoke-explore-cache.mjs",
23
23
  "check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
24
24
  "check:route-guard": "node scripts/smoke-route-guard.mjs",
25
+ "check:reviewed-memory": "node scripts/smoke-reviewed-memory.mjs",
25
26
  "test": "npm run test:smoke",
26
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard",
27
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory",
27
28
  "test:search": "npm run build && npm run check:search",
28
29
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
29
30
  "test:all": "npm run test:memory"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents (Claude Code, OpenCode).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "access": "public"
9
9
  },
10
10
  "bin": {
11
- "cairn": "bin/cairn"
11
+ "cairn": "bin/cairn",
12
+ "cairn-container": "scripts/cairn-container"
12
13
  },
13
14
  "files": [
14
15
  "bin/",
@@ -18,6 +19,7 @@
18
19
  "opencode/",
19
20
  "examples/anythingllm/",
20
21
  "examples/overlay-distribution/",
22
+ "containers/",
21
23
  "schemas/",
22
24
  "docs/",
23
25
  "CHANGELOG.md",
@@ -0,0 +1,224 @@
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
+ workspace_volume="cairnkeep-workspace-$(safe_name "$(basename "$repo")")"
175
+ fi
176
+
177
+ require_engine
178
+ podman_base_args
179
+ PODMAN_ARGS+=(-i)
180
+ [ ! -t 0 ] || PODMAN_ARGS+=(-t)
181
+ PODMAN_ARGS+=(
182
+ --volume "$data_volume:/data:Z,U"
183
+ --volume "$home_volume:/home/cairn:Z,U"
184
+ --env "CAIRN_WORKSPACE_MODE=$mode"
185
+ )
186
+ if [ "$mode" = shared ]; then
187
+ PODMAN_ARGS+=(--volume "$repo:/workspace:rw,Z")
188
+ else
189
+ PODMAN_ARGS+=(
190
+ --volume "$repo:/source:ro,Z"
191
+ --volume "$workspace_volume:/workspace:Z,U"
192
+ )
193
+ fi
194
+ [ -z "$env_file" ] || PODMAN_ARGS+=(--env-file "$(absolute_file "$env_file")")
195
+
196
+ for spec in "${secret_specs[@]}"; do
197
+ name=${spec%%=*}
198
+ file=${spec#*=}
199
+ [ "$name" != "$spec" ] || fail "--secret requires NAME=FILE"
200
+ case "$name" in *[!A-Z0-9_]* | "") fail "invalid secret variable name: $name" ;; esac
201
+ file=$(absolute_file "$file")
202
+ check_secret_mode "$file"
203
+ target=$(safe_name "$name")
204
+ PODMAN_ARGS+=(
205
+ --volume "$file:/run/secrets/$target:ro,Z"
206
+ --env "${name}_FILE=/run/secrets/$target"
207
+ )
208
+ done
209
+
210
+ if [ "$#" -eq 0 ]; then
211
+ set -- bash
212
+ fi
213
+ exec "$ENGINE" "${PODMAN_ARGS[@]}" "$image" "$@"
214
+ }
215
+
216
+ cmd=${1:-help}
217
+ [ "$#" -eq 0 ] || shift
218
+ case "$cmd" in
219
+ stdio) run_stdio "$@" ;;
220
+ http) run_http "$@" ;;
221
+ workspace) run_workspace "$@" ;;
222
+ help | -h | --help) usage ;;
223
+ *) fail "unknown command: $cmd" ;;
224
+ 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
+ }