@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.
- package/CHANGELOG.md +33 -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 +25 -0
- package/docs/containers.md +187 -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 +31 -0
- package/mcp-memory-server/dist/embeddings.js +7 -2
- package/mcp-memory-server/dist/index.js +245 -8
- package/mcp-memory-server/package.json +2 -1
- package/package.json +4 -2
- package/scripts/cairn-container +224 -0
- package/scripts/doctor.sh +16 -3
- package/scripts/probe-container-memory.mjs +51 -0
- package/scripts/test-container-runner.sh +83 -0
- package/scripts/test-container.sh +183 -0
- package/scripts/test-doctor.sh +62 -2
- package/templates/env.example.template +1 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|