@cairnkeep/cli 2.1.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 CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable user-facing changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [2.2.0] - 2026-07-20
7
+
8
+ ### Added
9
+
10
+ - Add unprivileged memory-server and workspace OCI images with rootless Podman
11
+ launchers, authenticated HTTP deployment examples, and persistent storage.
12
+ - Add isolated-copy and explicit shared-checkout workspace modes for private
13
+ derived distributions.
14
+ - Verify stdio startup, HTTP authentication, memory persistence across
15
+ replacement, and both workspace modes in container CI.
16
+
6
17
  ## [2.1.0] - 2026-07-16
7
18
 
8
19
  ### Added
@@ -89,6 +100,7 @@ stored memories.
89
100
  - Preserve executable permissions and Bash 3.2 portability on macOS.
90
101
  - Add backup-first uninstall and SQLite-safe memory export/import guidance.
91
102
 
103
+ [2.2.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.1.0...v2.2.0
92
104
  [2.1.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.0.0...v2.1.0
93
105
  [2.0.0]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.3...v2.0.0
94
106
  [1.1.3]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.2...v1.1.3
package/README.md CHANGED
@@ -41,6 +41,24 @@ and 20 are end-of-life upstream.
41
41
  Linux, macOS, Bash 3.2 portability, and clean Node 22/24/26 runtime checks
42
42
  are exercised in CI.
43
43
 
44
+ ## Try with Podman
45
+
46
+ The minimal OCI image runs the memory server as an unprivileged user and keeps
47
+ all databases in a named volume:
48
+
49
+ ```bash
50
+ podman run --rm -i \
51
+ --userns=keep-id:uid=10001,gid=10001 \
52
+ --read-only --cap-drop=all --security-opt=no-new-privileges \
53
+ --tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
54
+ --volume cairnkeep-data:/data:Z,U \
55
+ ghcr.io/cairnkeep/cairnkeep:latest stdio
56
+ ```
57
+
58
+ For persistent authenticated HTTP, rootless Quadlet, isolated workspace mode,
59
+ storage paths, secrets, and private derived images, see
60
+ **[Containers](docs/containers.md)**.
61
+
44
62
  ## Components
45
63
 
46
64
  - **`mcp-memory-server/`** — an MCP server exposing durable, scoped memory
@@ -166,6 +184,7 @@ search):
166
184
  | `CAIRN_LLM_EXTRACTION_MODEL` | Chat model used for memory extraction |
167
185
  | `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
168
186
  | `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
187
+ | `CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS` | Embedding request timeout before substring fallback (default `15000`) |
169
188
  | `CAIRN_AGENTFS_BASE_DIR` | Base dir for global memory scopes (default `~/.cairnkeep`) |
170
189
  | `CAIRN_GIT_PROVIDER` | Git host for collaboration commands: `github`\|`gitlab`\|`codeberg`\|`forgejo`\|`none` ([docs/git-providers.md](docs/git-providers.md)) |
171
190
  | `CAIRN_ROUTE_ENDPOINT` | Base URL of an already-running token-miser routing/tiering proxy (unset → `route_check` is inert) |
@@ -189,6 +208,7 @@ enabling them.
189
208
  - **Managed overlay distributions** (wrapper CLI, policy lock, private registry, rollback) — [docs/overlay-distributions.md](docs/overlay-distributions.md)
190
209
  - **Full operating guide** — [docs/operating.md](docs/operating.md)
191
210
  - **Memory storage and deployment** — [docs/storage.md](docs/storage.md)
211
+ - **Podman and OCI containers** — [docs/containers.md](docs/containers.md)
192
212
  - **Privacy and data flow** — [docs/privacy-and-data-flow.md](docs/privacy-and-data-flow.md)
193
213
  - **Git providers** — [docs/git-providers.md](docs/git-providers.md)
194
214
  - **Support** — [SUPPORT.md](SUPPORT.md)
@@ -0,0 +1,29 @@
1
+ services:
2
+ cairnkeep:
3
+ image: ghcr.io/cairnkeep/cairnkeep:${CAIRNKEEP_VERSION:-latest}
4
+ command: ["http"]
5
+ restart: unless-stopped
6
+ read_only: true
7
+ cap_drop: ["ALL"]
8
+ security_opt:
9
+ - no-new-privileges:true
10
+ environment:
11
+ MCP_HTTP_HOST: 0.0.0.0
12
+ MCP_HTTP_PORT: "7801"
13
+ CAIRN_MEMORY_HTTP_ALLOWED_HOSTS: ${CAIRN_MEMORY_HTTP_ALLOWED_HOSTS:-localhost:7801,127.0.0.1:7801}
14
+ CAIRN_MEMORY_HTTP_TOKEN_FILE: /run/secrets/http-token
15
+ ports:
16
+ - "127.0.0.1:${CAIRNKEEP_PORT:-7801}:7801"
17
+ volumes:
18
+ - cairnkeep-data:/data
19
+ secrets:
20
+ - http-token
21
+ tmpfs:
22
+ - /tmp:size=64m,mode=1777
23
+
24
+ volumes:
25
+ cairnkeep-data:
26
+
27
+ secrets:
28
+ http-token:
29
+ file: ${CAIRNKEEP_HTTP_TOKEN_FILE:-./secrets/http-token}
@@ -0,0 +1,54 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ load_secret() {
5
+ name=$1
6
+ eval "file=\${${name}_FILE:-}"
7
+ eval "value=\${${name}:-}"
8
+
9
+ if [ -n "$file" ] && [ -n "$value" ]; then
10
+ echo "cairnkeep: set either $name or ${name}_FILE, not both" >&2
11
+ exit 64
12
+ fi
13
+ if [ -n "$file" ]; then
14
+ if [ ! -f "$file" ] || [ ! -r "$file" ]; then
15
+ echo "cairnkeep: secret file for $name is not readable" >&2
16
+ exit 66
17
+ fi
18
+ value=$(cat "$file")
19
+ export "$name=$value"
20
+ fi
21
+ }
22
+
23
+ load_secret CAIRN_MEMORY_HTTP_TOKEN
24
+ load_secret CAIRN_LLM_API_KEY
25
+ load_secret ANYTHINGLLM_API_KEY
26
+
27
+ mode=${1:-stdio}
28
+ shift || true
29
+
30
+ case "$mode" in
31
+ stdio)
32
+ unset MCP_HTTP_HOST MCP_HTTP_PORT
33
+ cd "${CAIRN_SERVER_WORKDIR:-/data/project}"
34
+ exec cairn memory-server "$@"
35
+ ;;
36
+ http)
37
+ if [ -z "${CAIRN_MEMORY_HTTP_TOKEN:-}" ]; then
38
+ echo "cairnkeep: HTTP mode requires CAIRN_MEMORY_HTTP_TOKEN or CAIRN_MEMORY_HTTP_TOKEN_FILE" >&2
39
+ exit 64
40
+ fi
41
+ export MCP_HTTP_HOST=${MCP_HTTP_HOST:-0.0.0.0}
42
+ export MCP_HTTP_PORT=${MCP_HTTP_PORT:-7801}
43
+ allowed_hosts=${CAIRN_MEMORY_HTTP_ALLOWED_HOSTS:-}
44
+ export CAIRN_MEMORY_HTTP_ALLOWED_HOSTS="${allowed_hosts:+$allowed_hosts,}localhost:${MCP_HTTP_PORT},127.0.0.1:${MCP_HTTP_PORT}"
45
+ cd "${CAIRN_SERVER_WORKDIR:-/data/project}"
46
+ exec cairn memory-server "$@"
47
+ ;;
48
+ cairn)
49
+ exec cairn "$@"
50
+ ;;
51
+ *)
52
+ exec "$mode" "$@"
53
+ ;;
54
+ esac
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const port = process.env.MCP_HTTP_PORT?.trim();
4
+ if (!port) {
5
+ process.exit(0);
6
+ }
7
+
8
+ let token = process.env.CAIRN_MEMORY_HTTP_TOKEN?.trim();
9
+ const tokenFile = process.env.CAIRN_MEMORY_HTTP_TOKEN_FILE?.trim();
10
+ if (!token && tokenFile) {
11
+ try {
12
+ token = readFileSync(tokenFile, "utf8").trim();
13
+ } catch {
14
+ process.exit(1);
15
+ }
16
+ }
17
+ if (!token) {
18
+ process.exit(1);
19
+ }
20
+
21
+ const body = {
22
+ jsonrpc: "2.0",
23
+ id: 1,
24
+ method: "initialize",
25
+ params: {
26
+ protocolVersion: "2025-06-18",
27
+ capabilities: {},
28
+ clientInfo: { name: "cairnkeep-container-health", version: "1" },
29
+ },
30
+ };
31
+
32
+ try {
33
+ const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
34
+ method: "POST",
35
+ headers: {
36
+ Authorization: `Bearer ${token}`,
37
+ "Content-Type": "application/json",
38
+ Accept: "application/json, text/event-stream",
39
+ },
40
+ body: JSON.stringify(body),
41
+ signal: AbortSignal.timeout(4000),
42
+ });
43
+ process.exit(response.ok ? 0 : 1);
44
+ } catch {
45
+ process.exit(1);
46
+ }
@@ -0,0 +1,2 @@
1
+ [Volume]
2
+ VolumeName=cairnkeep-data
@@ -0,0 +1,32 @@
1
+ [Unit]
2
+ Description=Cairnkeep memory server
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Container]
7
+ Image=ghcr.io/cairnkeep/cairnkeep:latest
8
+ ContainerName=cairnkeep
9
+ Exec=http
10
+ UserNS=keep-id:uid=10001,gid=10001
11
+ Volume=cairnkeep-data.volume:/data:Z
12
+ Secret=cairnkeep-http-token,type=mount,target=http-token
13
+ Environment=MCP_HTTP_HOST=0.0.0.0
14
+ Environment=MCP_HTTP_PORT=7801
15
+ Environment=CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=localhost:7801,127.0.0.1:7801
16
+ Environment=CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token
17
+ PublishPort=127.0.0.1:7801:7801
18
+ ReadOnly=true
19
+ DropCapability=all
20
+ HealthCmd=node /usr/local/lib/cairnkeep/container-healthcheck.mjs
21
+ HealthInterval=30s
22
+ HealthTimeout=5s
23
+ HealthStartPeriod=10s
24
+ HealthRetries=3
25
+ PodmanArgs=--security-opt=no-new-privileges
26
+
27
+ [Service]
28
+ Restart=on-failure
29
+ RestartSec=5
30
+
31
+ [Install]
32
+ WantedBy=default.target
@@ -0,0 +1,25 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ mode=${CAIRN_WORKSPACE_MODE:-shared}
5
+ case "$mode" in
6
+ shared)
7
+ ;;
8
+ sandbox)
9
+ if [ ! -d /source ]; then
10
+ echo "cairnkeep: sandbox mode requires a read-only repository mounted at /source" >&2
11
+ exit 66
12
+ fi
13
+ if [ ! -e /workspace/.cairnkeep-sandbox-ready ]; then
14
+ cp -a /source/. /workspace/
15
+ : > /workspace/.cairnkeep-sandbox-ready
16
+ fi
17
+ ;;
18
+ *)
19
+ echo "cairnkeep: CAIRN_WORKSPACE_MODE must be shared or sandbox" >&2
20
+ exit 64
21
+ ;;
22
+ esac
23
+
24
+ cd /workspace
25
+ exec /usr/local/bin/cairn-container-entrypoint "$@"
@@ -0,0 +1,187 @@
1
+ # Containers
2
+
3
+ Cairnkeep publishes two OCI images for Podman and other OCI-compatible
4
+ engines:
5
+
6
+ | Image | Purpose |
7
+ |---|---|
8
+ | `ghcr.io/cairnkeep/cairnkeep:<version>` | Minimal MCP memory server, over stdio or authenticated HTTP |
9
+ | `ghcr.io/cairnkeep/cairnkeep-workspace:<version>` | Derivation base with Cairnkeep, Git, SSH, and ripgrep |
10
+
11
+ Both images run as the unprivileged user `10001:10001`. The workspace image is
12
+ not a preconfigured coding harness: a private distribution can derive from it
13
+ and install only its approved harnesses and policies.
14
+
15
+ ## Local stdio
16
+
17
+ After installing `@cairnkeep/cli`, register the container launcher rather than
18
+ the host memory server:
19
+
20
+ ```bash
21
+ claude mcp add cairn-memory -s user -- cairn-container stdio
22
+ ```
23
+
24
+ Each harness session starts an ephemeral server container. Memory survives in
25
+ the `cairnkeep-data` named volume. The container does not discover or contact a
26
+ remote Cairnkeep server.
27
+
28
+ The equivalent direct Podman command is:
29
+
30
+ ```bash
31
+ podman run --rm -i \
32
+ --userns=keep-id:uid=10001,gid=10001 \
33
+ --read-only --cap-drop=all --security-opt=no-new-privileges \
34
+ --tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
35
+ --volume cairnkeep-data:/data:Z,U \
36
+ ghcr.io/cairnkeep/cairnkeep:latest stdio
37
+ ```
38
+
39
+ Pin a version instead of `latest` for managed installations.
40
+
41
+ ## Authenticated HTTP
42
+
43
+ Generate the bearer token into a private file and start a loopback-only
44
+ listener:
45
+
46
+ ```bash
47
+ install -m 700 -d ~/.config/cairnkeep/secrets
48
+ openssl rand -hex 32 > ~/.config/cairnkeep/secrets/http-token
49
+ chmod 600 ~/.config/cairnkeep/secrets/http-token
50
+
51
+ cairn-container http \
52
+ --token-file ~/.config/cairnkeep/secrets/http-token \
53
+ --port 7801
54
+ ```
55
+
56
+ The launcher refuses token files that are symlinks or have a mode other than
57
+ `0400` or `0600`. It mounts the token read-only instead of putting it in the
58
+ container environment or image.
59
+
60
+ The listener is deliberately published only on `127.0.0.1`. For access from
61
+ other machines, keep it on loopback behind a TLS reverse proxy or encrypted
62
+ private network. Add the externally visible `Host` header to the DNS-rebinding
63
+ allowlist:
64
+
65
+ ```bash
66
+ cairn-container http \
67
+ --token-file ~/.config/cairnkeep/secrets/http-token \
68
+ --allowed-hosts memory.example.com
69
+ ```
70
+
71
+ The container always adds its own loopback address for health checks.
72
+
73
+ One HTTP server and bearer token form one trust domain. They are not a
74
+ multi-tenant authorization boundary; see [Memory storage and
75
+ deployment](storage.md).
76
+
77
+ ## Persistent service
78
+
79
+ [`containers/compose.yaml`](../containers/compose.yaml) is a portable starting
80
+ point:
81
+
82
+ ```bash
83
+ mkdir -p containers/secrets
84
+ openssl rand -hex 32 > containers/secrets/http-token
85
+ chmod 600 containers/secrets/http-token
86
+ podman compose -f containers/compose.yaml up -d
87
+ ```
88
+
89
+ Set `CAIRNKEEP_VERSION` to pin the image and
90
+ `CAIRN_MEMORY_HTTP_ALLOWED_HOSTS` when a reverse proxy sends a different host
91
+ name. The named volume contains all memory state.
92
+
93
+ For rootless Podman managed by systemd, copy the files from
94
+ [`containers/quadlet`](../containers/quadlet) to
95
+ `~/.config/containers/systemd/`, create the referenced secret, and reload:
96
+
97
+ ```bash
98
+ printf '%s' "$(openssl rand -hex 32)" |
99
+ podman secret create cairnkeep-http-token -
100
+ systemctl --user daemon-reload
101
+ systemctl --user enable --now cairnkeep.service
102
+ ```
103
+
104
+ Review the image tag, published port, allowed hosts, and backup location before
105
+ using the sample as a long-running service.
106
+
107
+ ## Storage and backups
108
+
109
+ Inside the server image:
110
+
111
+ | Data | Container path |
112
+ |---|---|
113
+ | Local/legacy project database | `/data/project/.agentfs/project.db` |
114
+ | Named/global scopes | `/data/scopes/<scope>.db` |
115
+ | Header-routed HTTP projects | `/data/scopes/projects/<project-id>.db` |
116
+
117
+ All three paths are below `/data`, so replacing the container while retaining
118
+ the volume does not move or delete memory. Removing the volume does delete the
119
+ store. Back up the volume with the service stopped, or use SQLite online
120
+ backups for each database as described in [storage.md](storage.md).
121
+
122
+ ## Model and RAG endpoints
123
+
124
+ Embedding, extraction, and document-RAG services remain optional external
125
+ services. Supply non-secret settings with an env file:
126
+
127
+ ```dotenv
128
+ CAIRN_LLM_API_URL=https://models.example.com/v1
129
+ CAIRN_LLM_EXTRACTION_MODEL=example-chat-model
130
+ CAIRN_MEMORY_EMBEDDING_URL=https://models.example.com/v1
131
+ CAIRN_MEMORY_EMBEDDING_MODEL=example-embedding-model
132
+ ```
133
+
134
+ For workspace containers, pass keys as files:
135
+
136
+ ```bash
137
+ cairn-container workspace --repo . \
138
+ --env-file ~/.config/cairnkeep/container.env \
139
+ --secret CAIRN_LLM_API_KEY=~/.config/cairnkeep/secrets/llm-key
140
+ ```
141
+
142
+ The entrypoint reads `CAIRN_LLM_API_KEY_FILE`,
143
+ `CAIRN_MEMORY_HTTP_TOKEN_FILE`, and `ANYTHINGLLM_API_KEY_FILE`. Do not bake
144
+ credentials, cookies, SSH private keys, or a populated `.env` into an image.
145
+
146
+ ## Isolated workspaces
147
+
148
+ The default `sandbox` mode copies the selected repository into a persistent
149
+ named volume. Commands can change that copy but cannot write to the host
150
+ checkout:
151
+
152
+ ```bash
153
+ cairn-container workspace --repo /path/to/repository
154
+ ```
155
+
156
+ Rerunning the command with the same workspace volume resumes the copy. Use
157
+ `--workspace-volume` when repositories have the same basename or when an
158
+ explicit lifecycle is preferable.
159
+
160
+ `shared` mode is opt-in and bind-mounts the checkout read/write:
161
+
162
+ ```bash
163
+ cairn-container workspace --repo /path/to/repository --mode shared
164
+ ```
165
+
166
+ The container has normal outbound network access but receives no host home
167
+ directory, container-engine socket, or host credentials unless the operator
168
+ mounts them. For stricter execution, add network policy and resource limits in
169
+ the private distribution.
170
+
171
+ ## Private derived images
172
+
173
+ A managed overlay should derive from a pinned workspace image:
174
+
175
+ ```dockerfile
176
+ ARG CAIRNKEEP_VERSION
177
+ FROM ghcr.io/cairnkeep/cairnkeep-workspace:${CAIRNKEEP_VERSION}
178
+
179
+ USER root
180
+ # Install the approved harness package from the approved registry.
181
+ USER 10001:10001
182
+ ```
183
+
184
+ The derived image may contain non-secret organization policy and launchers.
185
+ Machine credentials and repository access remain runtime secrets. Build and
186
+ publish that image from the private overlay repository to its approved private
187
+ registry; do not add private configuration to the public Cairnkeep image.
package/docs/operating.md CHANGED
@@ -23,6 +23,8 @@ installs. This guide covers all three in order.
23
23
  - Optional: an OpenAI-compatible LLM endpoint for memory extraction and
24
24
  embedding-ranked search. Without it, memory search degrades to substring
25
25
  matching — everything else still works.
26
+ - Optional: rootless Podman for the containerized memory server and isolated
27
+ workspace base described in [Containers](containers.md).
26
28
 
27
29
  ## Setup order (Claude Code)
28
30
 
@@ -87,6 +89,17 @@ Step 3 installs into `~/.claude` (override with `CLAUDE_CONFIG_DIR` or
87
89
  Re-running `sync-claude-assets.sh --apply` is idempotent; use `--check` to see
88
90
  drift without writing. Run it again whenever you pull changes to `claude/`.
89
91
 
92
+ To keep the memory-server runtime in a container, install the npm package for
93
+ the launcher and replace the registration command with:
94
+
95
+ ```bash
96
+ claude mcp add cairn-memory -s user -- cairn-container stdio
97
+ ```
98
+
99
+ This still stores memory locally, in the `cairnkeep-data` Podman volume. It
100
+ does not configure remote HTTP storage or containerize the harness. See
101
+ [Containers](containers.md) for those separate deployment choices.
102
+
90
103
  ## Setup order (OpenCode)
91
104
 
92
105
  OpenCode is a secondary path. Steps 1, 4, and 5 are identical (use
@@ -146,6 +159,7 @@ vendor or host.
146
159
  | `CAIRN_LLM_EXTRACTION_MODEL` | Chat model for `memory-capture` extraction |
147
160
  | `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
148
161
  | `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
162
+ | `CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS` | Embedding request timeout before substring fallback (default `15000`) |
149
163
  | `CAIRN_AGENTFS_BASE_DIR` | Server-side base dir for named/global memory scopes (default `~/.cairnkeep`); it does not affect `project` scope |
150
164
  | `CAIRN_GIT_PROVIDER` | Git host for collaboration commands: `github`\|`gitlab`\|`codeberg`\|`forgejo`\|`none`. See [git-providers.md](git-providers.md) |
151
165
  | `CAIRN_ROUTE_ENDPOINT` | Base URL of an already-running token-miser routing/tiering proxy (unset → the `route_check` tool is inert) |
@@ -77,6 +77,14 @@ checkout. Avoid `postinstall` enrollment: installing a package must not silently
77
77
  replace machine policy. Provide an explicit enrollment command with a retained
78
78
  rollback target.
79
79
 
80
+ For container delivery, derive from an exact
81
+ `ghcr.io/cairnkeep/cairnkeep-workspace:<version>` tag and publish only to the
82
+ approved private registry. Install approved harnesses and non-secret policy in
83
+ the derived image, but inject credentials at runtime through the platform's
84
+ secret mechanism. The public image deliberately contains no organization
85
+ profile and never selects a remote memory service. See
86
+ [Containers](containers.md) for the base-image and workspace contracts.
87
+
80
88
  ## Release and fleet safety
81
89
 
82
90
  Every release should verify:
@@ -36,6 +36,12 @@ Project-scoped and named/global database locations are documented in
36
36
  the remote server host; changing a storage environment variable on the client
37
37
  does not relocate that server's databases.
38
38
 
39
+ The official container stores all databases below `/data`. A named volume
40
+ persists them after container replacement and remains sensitive data. Sandbox
41
+ workspace mode also retains a repository copy in its named volume. Neither
42
+ volume is encrypted by Cairnkeep; remove it explicitly when its retention
43
+ period ends. See [Containers](containers.md).
44
+
39
45
  ## Credentials and transport
40
46
 
41
47
  Keep API keys and bearer tokens out of repositories and command output. Load
package/docs/releasing.md CHANGED
@@ -14,20 +14,35 @@ from a workstation.
14
14
  tests, and publishes `@cairnkeep/cli` with npm provenance.
15
15
  6. The workflow attaches the npm tarball, a CycloneDX SBOM, and SHA-256 checksums
16
16
  to the GitHub Release.
17
+ 7. The workflow publishes versioned `linux/amd64` and `linux/arm64`
18
+ memory-server and workspace images to GHCR. It also updates `latest` for a
19
+ stable release or `next` for a prerelease.
20
+ 8. Verify both image digests, SBOM attestations, provenance attestations, and an
21
+ anonymous pull before announcing the release.
17
22
 
18
23
  Stable versions must use a stable GitHub Release and publish to npm's `latest`
19
24
  tag. SemVer prerelease versions must use a GitHub prerelease and publish to
20
25
  `next`. A mismatch fails before publication.
21
26
 
22
- The workflow is safe to rerun: it skips `npm publish` when that exact version is
23
- already present, then regenerates and replaces the release artifacts.
27
+ The workflow is safe to rerun: it skips `npm publish` and each versioned OCI
28
+ image when that exact version is already present. It regenerates release
29
+ artifacts, retries attestations against the resolved image digests, and moves
30
+ only the `latest` or `next` channel tag. A versioned image tag is never rebuilt
31
+ or moved.
24
32
 
25
33
  ## Repository configuration
26
34
 
27
35
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
28
36
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
29
- to the npm account's security policy. The workflow grants only `contents: write`
30
- and `id-token: write`; the latter is required for npm provenance.
37
+ to the npm account's security policy. The workflow grants `contents: write`,
38
+ `packages: write`, `attestations: write`, and `id-token: write`; the last two
39
+ are required for registry-backed build provenance.
40
+
41
+ GitHub creates each new GHCR package as private. After the first container
42
+ release, a package administrator must change both `cairnkeep` and
43
+ `cairnkeep-workspace` to Public in their Package settings. This is a one-time,
44
+ irreversible visibility change for each package; subsequent releases remain
45
+ automatic. Confirm an unauthenticated pull after changing visibility.
31
46
 
32
47
  Treat a published version as immutable. If a release is wrong, fix it in a new
33
48
  version rather than moving its tag or replacing the npm package.
package/docs/storage.md CHANGED
@@ -29,6 +29,12 @@ services used to process/search memory. They do not change where the SQLite
29
29
  databases are stored. Git-provider and routing configuration do not change
30
30
  storage either.
31
31
 
32
+ When the memory server runs in the official container, the same rule applies:
33
+ the process stores databases below `/data`, normally backed by the
34
+ `cairnkeep-data` named volume. Replacing the container preserves that volume;
35
+ removing the volume removes the databases. See [Containers](containers.md) for
36
+ the exact paths and backup boundary.
37
+
32
38
  ## Remote HTTP mode
33
39
 
34
40
  Remote storage is explicit. An operator starts Cairnkeep in HTTP mode on a
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ const DEFAULT_EMBEDDING_TIMEOUT_MS = 15000;
4
5
  // Resolve the embeddings endpoint from the environment. Returns null when the
5
6
  // API key, URL, or model name is not configured, so callers can fall back to
6
7
  // substring search. Point CAIRN_LLM_API_URL (or CAIRN_MEMORY_EMBEDDING_URL) at
@@ -21,7 +22,11 @@ export function getEmbeddingConfig() {
21
22
  if (!model) {
22
23
  return null;
23
24
  }
24
- return { apiUrl, apiKey, model };
25
+ const rawTimeout = Number(process.env.CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS);
26
+ const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout >= 100 && rawTimeout <= 120000
27
+ ? Math.floor(rawTimeout)
28
+ : DEFAULT_EMBEDDING_TIMEOUT_MS;
29
+ return { apiUrl, apiKey, model, timeoutMs };
25
30
  }
26
31
  export function hashText(text) {
27
32
  return createHash("sha1").update(text).digest("hex");
@@ -59,7 +64,7 @@ export async function embedTexts(config, texts, chunkSize = 64) {
59
64
  "Content-Type": "application/json",
60
65
  },
61
66
  body: JSON.stringify({ model: config.model, input: chunk }),
62
- signal: AbortSignal.timeout(120000),
67
+ signal: AbortSignal.timeout(config.timeoutMs),
63
68
  });
64
69
  if (!response.ok) {
65
70
  const text = await response.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.1.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
+ }
@@ -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.