@cairnkeep/cli 2.22.2 → 2.22.4
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 +67 -1
- package/containers/healthcheck.mjs +27 -33
- package/containers/quadlet/cairnkeep.container +1 -1
- package/docs/containers.md +12 -0
- package/docs/domain-knowledge.md +15 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +8 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +52 -2
- package/docs/learning/video-scripts/V28-security-assurance.md +45 -0
- package/docs/native-windows.md +10 -0
- package/docs/operating.md +19 -1
- package/docs/privacy-and-data-flow.md +6 -0
- package/docs/releasing.md +3 -2
- package/docs/security-assurance.md +67 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/capability-registry.js +2 -15
- package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
- package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
- package/mcp-memory-server/dist/eval-plan.js +15 -22
- package/mcp-memory-server/dist/eval-runner.js +17 -10
- package/mcp-memory-server/dist/eval-workspace.js +82 -23
- package/mcp-memory-server/dist/failure-signature.js +29 -8
- package/mcp-memory-server/dist/graph-cli.js +25 -10
- package/mcp-memory-server/dist/okf.js +4 -9
- package/mcp-memory-server/dist/playbook-receipt.js +3 -17
- package/mcp-memory-server/dist/skill-store.js +10 -26
- package/mcp-memory-server/dist/stable-file.js +85 -0
- package/mcp-memory-server/dist/work-evidence-store.js +7 -5
- package/omp/extensions/cairnkeep-capture.ts +29 -17
- package/package.json +1 -1
- package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
- package/scripts/lib/stable-file.mjs +75 -0
- package/scripts/playbook-instructions.mjs +42 -20
- package/scripts/setup-core.mjs +5 -36
- package/scripts/setup-reconcile.mjs +32 -32
- package/scripts/setup.mjs +25 -34
- package/scripts/test-ci-sharding.sh +5 -0
- package/scripts/test-container-health.mjs +56 -0
- package/scripts/test-container.sh +8 -1
- package/scripts/test-learning-docs.sh +8 -0
- package/scripts/test-omp-staged-security.mjs +110 -0
- package/scripts/test-runtime-file-security.mjs +311 -0
- package/scripts/test-setup-diagnosis.mjs +97 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/verify-omp-capture.mjs +1 -0
- package/scripts/windows-platform.mjs +54 -22
|
@@ -47,6 +47,13 @@ be enabled and evaluated independently.
|
|
|
47
47
|
| Traversal, alternate separators, drive paths, ADS, forbidden Windows characters/device aliases, invalid Unicode, or case collisions in portable data | One cross-platform path validator is used by context packs, OKF, artifacts, and evaluations; deterministic adversarial cases run in the smoke suite | Filesystems can have additional locale- or mount-specific semantics; containment checks remain required at every write |
|
|
48
48
|
| Symlink or special-file substitution | Canonical roots, non-symlink regular-file checks, atomic replacement, and existing symlink/race regression tests | A hostile process running as the same account can race or replace resources outside Cairnkeep's process boundary |
|
|
49
49
|
| Replaced or growing private pack state between inspection and use | One descriptor for metadata and bounded content reads; name/parent identity rechecks, private ownership and encoding validation; deterministic replacement/denial controls | Concurrent legitimate replacement can require retry; these checks are not per-user ACLs or a defense against an account with full local control |
|
|
50
|
+
| Replaced capability configuration, skill state or work evidence | Inspected/opened file identity agreement, bounded descriptor reads, metadata/name/parent rechecks and strict UTF-8; private ledgers retain owner/ACL checks while ordinary project files retain their modes | Concurrent legitimate writes can require retry; this does not make skill mutation transactional against another same-account writer |
|
|
51
|
+
| Replaced or growing evaluation, OKF, graph, receipt or derived-cache inputs | Descriptor-bound byte caps and metadata/name/parent rechecks; snapshots publish the validated bytes, private receipts retain owner/ACL checks, and rejected derived summaries are rebuilt | Concurrent legitimate changes can require retry; this does not make directory traversal, graph publication or snapshot exposure transactional against another same-account writer |
|
|
52
|
+
| Replaced setup policy/state/owned assets or instruction files | Bounded descriptor reads, strict UTF-8, observed-target and immediate-parent rechecks before publication, exclusive temporary files and retained instruction modes | Publication checks are point-in-time, not compare-and-swap or an all-project transaction; same-account processes remain trusted |
|
|
53
|
+
| Symlinked, growing or oversized OMP staged candidates | Real staging directories, portable filenames and independent descriptor reads capped at 8 KiB; unsafe entries are omitted without blocking the session | Candidate contents are untrusted proposals; these checks neither approve them nor automatically write durable memory |
|
|
54
|
+
| Evaluation overlay target substitution or denied Windows replacement | Exclusive descriptor-written temporary files, checked parents/targets, and bounded retries that never unlink live data | Final-check-to-publication races are not eliminated against hostile same-account writers; a successful publication followed by an ACL failure is not a rollback |
|
|
55
|
+
| Redirected provider requests, unbounded responses or provider-error disclosure | Explicit validated endpoints, manual redirects, streaming response caps, body cancellation and payload-free diagnostics; container probes use the server's numeric port and token parsers | External retrieval still sends the selected query to the configured provider; remote AnythingLLM HTTP remains supported for compatibility and requires operator transport review |
|
|
56
|
+
| Crafted failure messages exhausting regex parsing | Delimited source-path parsing without ambiguous repeated path groups; timeout-isolated adversarial controls and byte-equivalent legacy v1 fixtures | This is targeted parser regression coverage, not a general input sandbox or coverage-guided fuzzing campaign |
|
|
50
57
|
| Concurrent context-pack project pointer updates | Exclusive directory locks, bounded Windows contention retries, unsafe-lock rejection, injected denial controls and native cross-process exclusion tests | A persistent lock can block progress; recovery never infers abandonment or steals the lock |
|
|
51
58
|
| Unauthenticated or rebound HTTP access | HTTP refuses to start without a token, compares it in constant time, validates `Host`, denies CORS by default, and bounds headers, request time, keep-alive time, and bodies to 8 MiB | Bearer tokens are not user identities; deploy TLS at the ingress for non-loopback traffic |
|
|
52
59
|
| Excessive MCP authority | Explicit MCP annotations, restrictive profiles, capability intersection, and separate HTTP consent for sensitive optional tools | The default profile remains `full` for compatibility; operators should choose `read-only` or `custom` for observation-only clients |
|
|
@@ -140,3 +147,63 @@ The native-Windows matrix runs the CLI, package, and global-install contracts on
|
|
|
140
147
|
Node 22, 24, and 26. It deterministically partitions the complete memory-server
|
|
141
148
|
smoke catalog across those lanes exactly once; the Linux matrix still runs the
|
|
142
149
|
complete catalog independently on every supported Node version.
|
|
150
|
+
|
|
151
|
+
The signature smoke check exercises crafted slash-heavy messages in a child
|
|
152
|
+
process with a five-second deadline, plus a deterministic generated corpus.
|
|
153
|
+
The runtime file-read control substitutes valid files after inspection through
|
|
154
|
+
the public capability, skill and work-evidence readers, and separately checks
|
|
155
|
+
growth, special-file substitution, byte limits, encoding and descriptor cleanup.
|
|
156
|
+
It runs as part of `check:security-assurance`; it can also be run directly:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
node mcp-memory-server/scripts/smoke-note-signatures.mjs
|
|
160
|
+
node mcp-memory-server/scripts/smoke-runtime-file-reads.mjs
|
|
161
|
+
node mcp-memory-server/scripts/smoke-bounded-input-reads.mjs
|
|
162
|
+
node mcp-memory-server/scripts/smoke-workspace-file-security.mjs
|
|
163
|
+
node mcp-memory-server/scripts/smoke-outbound-security.mjs
|
|
164
|
+
node scripts/test-runtime-file-security.mjs
|
|
165
|
+
node scripts/test-omp-staged-security.mjs
|
|
166
|
+
node scripts/test-container-health.mjs
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Build the server first when testing a source checkout. These regressions do not
|
|
170
|
+
assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
|
|
171
|
+
individually and use a fresh default-branch analysis to confirm alert status;
|
|
172
|
+
never dismiss an uninvestigated finding merely to improve the score.
|
|
173
|
+
|
|
174
|
+
The bounded-input control exercises real evaluation, OKF import/export, graph
|
|
175
|
+
status, receipt and progressive-cache readers. Valid-file substitution and
|
|
176
|
+
same-inode growth are injected at actual inspection/read boundaries; the test
|
|
177
|
+
checks that no unbounded `readFile` allocation follows growth. Evaluation inputs
|
|
178
|
+
retain their exact-byte binding and require UTF-8. Snapshot file/count and total
|
|
179
|
+
byte limits are enforced during collection, and publication uses the retained
|
|
180
|
+
bytes rather than reopening mutable source paths. Private receipt permissions
|
|
181
|
+
remain mandatory. Derived cache failures rebuild from the current visible
|
|
182
|
+
immutable content; read-only cache diagnosis reports issues without deleting
|
|
183
|
+
them. These controls do not grant pack or skill authority, and they do not
|
|
184
|
+
replace the native-Windows and full CI gates.
|
|
185
|
+
|
|
186
|
+
The setup/OMP/publication follow-up uses the same descriptor-first contract in
|
|
187
|
+
bootstrap JavaScript, without requiring a built server for setup. Setup policy,
|
|
188
|
+
state and owned-asset reads and `AGENTS.md` are capped at 1 MiB. Unrecorded
|
|
189
|
+
caller-owned assets retain the metadata-only diagnosis contract and are not
|
|
190
|
+
adopted or rewritten. POSIX private setup state still requires owner/mode
|
|
191
|
+
checks; Windows writer ACL hardening is unchanged, and bootstrap reads do not
|
|
192
|
+
introduce a new Windows ACL-verification claim. OMP candidates are capped at
|
|
193
|
+
8 KiB, including `/cairn-staged` display; unsafe entries remain invisible.
|
|
194
|
+
|
|
195
|
+
Workspace controls inject a target symlink at the mutation boundary and check
|
|
196
|
+
that outside content remains untouched. Root controls inject concurrent caller
|
|
197
|
+
edits, denied Windows replacements and colliding temporary names. Publication
|
|
198
|
+
uses exclusive temporaries, descriptor writes and flushes, and rechecks the
|
|
199
|
+
observed target and immediate parent. These are not directory-wide transactions
|
|
200
|
+
or a same-account sandbox. Windows compressed and decompressed archive input
|
|
201
|
+
is capped at 512 MiB; a multi-database import is not one atomic transaction.
|
|
202
|
+
|
|
203
|
+
Outbound controls stub `fetch` without network access, verify destination and
|
|
204
|
+
redirect authority, and inject oversized bodies and private failure sentinels.
|
|
205
|
+
AnythingLLM successful answers retain their prior field selection and JSON
|
|
206
|
+
fallback; its response cap is 8 MiB. OpenViking retains its 1 MiB cap, explicit
|
|
207
|
+
consents and HTTPS-or-loopback rule. Both reject malformed UTF-8 and suppress
|
|
208
|
+
network/stream diagnostics. Health probes use only `127.0.0.1` with a validated
|
|
209
|
+
port and the existing private token resolver, and never follow redirects.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import { isNoteDistillationEnabled } from "./note-schema.js";
|
|
3
|
+
import { readStableJson } from "./stable-file.js";
|
|
4
4
|
import { CAPABILITY_IDS, } from "./capability-schema.js";
|
|
5
5
|
const MAX_COMPATIBILITY_CONFIG_BYTES = 64 * 1024;
|
|
6
6
|
function environmentName(id) {
|
|
@@ -10,20 +10,7 @@ export async function isGraphCapabilityCompatible(projectRoot) {
|
|
|
10
10
|
const planningDirectory = join(projectRoot, ".planning");
|
|
11
11
|
const configPath = join(planningDirectory, "config.json");
|
|
12
12
|
try {
|
|
13
|
-
const
|
|
14
|
-
lstat(planningDirectory),
|
|
15
|
-
lstat(configPath),
|
|
16
|
-
]);
|
|
17
|
-
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
|
|
18
|
-
return false;
|
|
19
|
-
if (fileStat.isSymbolicLink() || !fileStat.isFile())
|
|
20
|
-
return false;
|
|
21
|
-
if (fileStat.size > MAX_COMPATIBILITY_CONFIG_BYTES)
|
|
22
|
-
return false;
|
|
23
|
-
const bytes = await readFile(configPath);
|
|
24
|
-
if (bytes.byteLength > MAX_COMPATIBILITY_CONFIG_BYTES)
|
|
25
|
-
return false;
|
|
26
|
-
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
13
|
+
const parsed = readStableJson(configPath, { label: "Graph compatibility configuration", maxBytes: MAX_COMPATIBILITY_CONFIG_BYTES });
|
|
27
14
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
28
15
|
return false;
|
|
29
16
|
const graphify = parsed.graphify;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir,
|
|
2
|
+
import { lstat, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { readStableJson } from "./stable-file.js";
|
|
4
5
|
function isDirectory(node) {
|
|
5
6
|
return "type" in node && node.type === "directory";
|
|
6
7
|
}
|
|
@@ -77,7 +78,7 @@ async function writeCache(path, record) {
|
|
|
77
78
|
catch (error) {
|
|
78
79
|
// Concurrent deterministic builders may publish the same record.
|
|
79
80
|
try {
|
|
80
|
-
|
|
81
|
+
readStableJson(path, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
|
|
81
82
|
}
|
|
82
83
|
catch {
|
|
83
84
|
throw error;
|
|
@@ -94,7 +95,7 @@ async function enrichPackFiles(files, cacheBaseDirectory) {
|
|
|
94
95
|
const cachePath = join(cacheBaseDirectory, packDigest, `${setDigest}.json`);
|
|
95
96
|
let cached;
|
|
96
97
|
try {
|
|
97
|
-
const parsed =
|
|
98
|
+
const parsed = readStableJson(cachePath, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
|
|
98
99
|
if (validCache(parsed, packDigest, setDigest, files))
|
|
99
100
|
cached = parsed;
|
|
100
101
|
}
|
|
@@ -381,20 +382,19 @@ export async function doctorProgressiveContextCache(cacheBaseDirectory, packs, o
|
|
|
381
382
|
for (const cacheEntry of (await readdir(packPath, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
382
383
|
const cachePath = join(packPath, cacheEntry.name);
|
|
383
384
|
const relative = `cache/context/${packEntry.name}/${cacheEntry.name}`;
|
|
384
|
-
const cacheInfo = await lstat(cachePath);
|
|
385
385
|
if (cacheEntry.name.endsWith(".tmp")) {
|
|
386
386
|
temporaryRemnants.push(relative);
|
|
387
387
|
await removeDerived(cachePath, relative);
|
|
388
388
|
continue;
|
|
389
389
|
}
|
|
390
390
|
const match = /^([a-f0-9]{64})\.json$/.exec(cacheEntry.name);
|
|
391
|
-
if (!match
|
|
391
|
+
if (!match) {
|
|
392
392
|
issues.push(`Invalid progressive context cache entry: ${relative}`);
|
|
393
393
|
await removeDerived(cachePath, relative);
|
|
394
394
|
continue;
|
|
395
395
|
}
|
|
396
396
|
try {
|
|
397
|
-
const value =
|
|
397
|
+
const value = readStableJson(cachePath, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
|
|
398
398
|
const invalid = validateCacheRecord(value, packEntry.name, match[1], installedFiles);
|
|
399
399
|
if (invalid)
|
|
400
400
|
throw new Error(invalid);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
const OPENVIKING_RESPONSE_LIMIT = 1024 * 1024;
|
|
3
|
+
const ANYTHINGLLM_RESPONSE_LIMIT = 8 * 1024 * 1024;
|
|
3
4
|
const OPENVIKING_DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
5
|
const OPENVIKING_MAX_TIMEOUT_MS = 120_000;
|
|
5
6
|
export function resolveDomainRetrievalProvider(env = process.env) {
|
|
@@ -56,14 +57,13 @@ function openVikingTimeout(env) {
|
|
|
56
57
|
return timeout;
|
|
57
58
|
}
|
|
58
59
|
function openVikingTargetUri(workspace) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
&& !/^viking:\/\/resources\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(workspace)) {
|
|
60
|
+
const target = workspace.startsWith("viking://")
|
|
61
|
+
? workspace : `viking://resources/${encodeURIComponent(workspace)}`;
|
|
62
|
+
if (target !== "viking://resources"
|
|
63
|
+
&& !/^viking:\/\/resources\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(target)) {
|
|
64
64
|
throw new Error("OpenViking workspace URI must stay below viking://resources.");
|
|
65
65
|
}
|
|
66
|
-
const path =
|
|
66
|
+
const path = target.slice("viking://resources".length);
|
|
67
67
|
let decoded;
|
|
68
68
|
try {
|
|
69
69
|
decoded = decodeURIComponent(path);
|
|
@@ -74,28 +74,49 @@ function openVikingTargetUri(workspace) {
|
|
|
74
74
|
if (decoded.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
75
75
|
throw new Error("OpenViking workspace URI must not contain traversal segments.");
|
|
76
76
|
}
|
|
77
|
-
return
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
class ResponseReadError extends Error {
|
|
80
|
+
}
|
|
81
|
+
async function discardResponse(response) {
|
|
82
|
+
try {
|
|
83
|
+
await response.body?.cancel();
|
|
84
|
+
}
|
|
85
|
+
catch { /* Cleanup must not leak provider diagnostics. */ }
|
|
78
86
|
}
|
|
79
|
-
async function boundedResponseText(response) {
|
|
87
|
+
async function boundedResponseText(response, maximumBytes = OPENVIKING_RESPONSE_LIMIT, label = "OpenViking") {
|
|
80
88
|
const advertisedLength = Number(response.headers.get("content-length"));
|
|
81
|
-
if (Number.isFinite(advertisedLength) && advertisedLength >
|
|
82
|
-
|
|
89
|
+
if (Number.isFinite(advertisedLength) && advertisedLength > maximumBytes) {
|
|
90
|
+
await discardResponse(response);
|
|
91
|
+
throw new ResponseReadError(`${label} response is too large.`);
|
|
83
92
|
}
|
|
84
93
|
if (!response.body)
|
|
85
94
|
return "";
|
|
86
95
|
const reader = response.body.getReader();
|
|
87
96
|
const chunks = [];
|
|
88
97
|
let length = 0;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
const { done, value } = await reader.read();
|
|
101
|
+
if (done)
|
|
102
|
+
break;
|
|
103
|
+
length += value.byteLength;
|
|
104
|
+
if (length > maximumBytes)
|
|
105
|
+
throw new ResponseReadError(`${label} response is too large.`);
|
|
106
|
+
chunks.push(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
try {
|
|
95
111
|
await reader.cancel();
|
|
96
|
-
throw new Error("OpenViking response is too large.");
|
|
97
112
|
}
|
|
98
|
-
|
|
113
|
+
catch { /* Preserve the payload-free failure. */ }
|
|
114
|
+
if (error instanceof ResponseReadError)
|
|
115
|
+
throw error;
|
|
116
|
+
throw new ResponseReadError(`${label} response could not be read or timed out.`);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
reader.releaseLock();
|
|
99
120
|
}
|
|
100
121
|
const joined = new Uint8Array(length);
|
|
101
122
|
let offset = 0;
|
|
@@ -103,7 +124,12 @@ async function boundedResponseText(response) {
|
|
|
103
124
|
joined.set(chunk, offset);
|
|
104
125
|
offset += chunk.byteLength;
|
|
105
126
|
}
|
|
106
|
-
|
|
127
|
+
try {
|
|
128
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new ResponseReadError(`${label} response is not valid UTF-8.`);
|
|
132
|
+
}
|
|
107
133
|
}
|
|
108
134
|
async function callAnythingLLM(workspace, query, env) {
|
|
109
135
|
const apiKey = env.ANYTHINGLLM_API_KEY;
|
|
@@ -111,23 +137,56 @@ async function callAnythingLLM(workspace, query, env) {
|
|
|
111
137
|
throw new Error("ANYTHINGLLM_API_KEY is not set.");
|
|
112
138
|
}
|
|
113
139
|
const baseUrl = env.ANYTHINGLLM_BASE_URL ?? "http://localhost:3001";
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
let endpoint;
|
|
141
|
+
try {
|
|
142
|
+
endpoint = new URL(baseUrl);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new Error("AnythingLLM base URL must be a valid HTTP(S) URL.");
|
|
146
|
+
}
|
|
147
|
+
if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:")
|
|
148
|
+
throw new Error("AnythingLLM base URL must use HTTP or HTTPS.");
|
|
149
|
+
if (endpoint.username || endpoint.password)
|
|
150
|
+
throw new Error("AnythingLLM base URL must not contain embedded credentials.");
|
|
151
|
+
if (endpoint.search || endpoint.hash)
|
|
152
|
+
throw new Error("AnythingLLM base URL must not contain a query string or fragment.");
|
|
153
|
+
if (!workspace || workspace === "." || workspace === "..")
|
|
154
|
+
throw new Error("AnythingLLM workspace must not be empty or a dot segment.");
|
|
155
|
+
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/api/v1/workspace/${encodeURIComponent(workspace)}/chat`;
|
|
156
|
+
let response;
|
|
157
|
+
try {
|
|
158
|
+
response = await fetch(endpoint, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: `Bearer ${apiKey}`,
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
message: query,
|
|
166
|
+
mode: "query",
|
|
167
|
+
}),
|
|
168
|
+
redirect: "manual",
|
|
169
|
+
signal: AbortSignal.timeout(120000),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error("AnythingLLM request failed or timed out.");
|
|
174
|
+
}
|
|
126
175
|
if (!response.ok) {
|
|
127
|
-
|
|
128
|
-
throw new Error(`AnythingLLM request failed with ${response.status}
|
|
176
|
+
await discardResponse(response);
|
|
177
|
+
throw new Error(`AnythingLLM request failed with ${response.status}.`);
|
|
178
|
+
}
|
|
179
|
+
const text = await boundedResponseText(response, ANYTHINGLLM_RESPONSE_LIMIT, "AnythingLLM");
|
|
180
|
+
let payload;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(text);
|
|
183
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
184
|
+
throw new Error();
|
|
185
|
+
payload = parsed;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error("AnythingLLM response is not a JSON object.");
|
|
129
189
|
}
|
|
130
|
-
const payload = (await response.json());
|
|
131
190
|
const directText = [
|
|
132
191
|
payload.textResponse,
|
|
133
192
|
payload.response,
|
|
@@ -204,6 +263,7 @@ async function callOpenViking(workspace, query, remote, env) {
|
|
|
204
263
|
}
|
|
205
264
|
const endpoint = openVikingEndpoint(env);
|
|
206
265
|
const timeout = openVikingTimeout(env);
|
|
266
|
+
const targetUri = openVikingTargetUri(workspace);
|
|
207
267
|
const apiKey = (env.CAIRN_OPENVIKING_API_KEY ?? env.OPENVIKING_API_KEY)?.trim();
|
|
208
268
|
let response;
|
|
209
269
|
try {
|
|
@@ -215,7 +275,7 @@ async function callOpenViking(workspace, query, remote, env) {
|
|
|
215
275
|
},
|
|
216
276
|
body: JSON.stringify({
|
|
217
277
|
query,
|
|
218
|
-
target_uri:
|
|
278
|
+
target_uri: targetUri,
|
|
219
279
|
context_type: ["resource"],
|
|
220
280
|
limit: 10,
|
|
221
281
|
}),
|
|
@@ -227,26 +287,21 @@ async function callOpenViking(workspace, query, remote, env) {
|
|
|
227
287
|
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
228
288
|
throw new Error("OpenViking request timed out.");
|
|
229
289
|
}
|
|
230
|
-
throw new Error(
|
|
290
|
+
throw new Error("OpenViking request failed.");
|
|
231
291
|
}
|
|
232
292
|
if (response.status >= 300 && response.status < 400) {
|
|
293
|
+
await discardResponse(response);
|
|
233
294
|
throw new Error(`OpenViking request refused redirect status ${response.status}.`);
|
|
234
295
|
}
|
|
235
296
|
if (!response.ok) {
|
|
297
|
+
await discardResponse(response);
|
|
236
298
|
throw new Error(`OpenViking request failed with ${response.status}.`);
|
|
237
299
|
}
|
|
238
300
|
if (!(response.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
|
|
301
|
+
await discardResponse(response);
|
|
239
302
|
throw new Error("OpenViking response is not JSON.");
|
|
240
303
|
}
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
text = await boundedResponseText(response);
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
if (error instanceof TypeError)
|
|
247
|
-
throw new Error("OpenViking response is not valid UTF-8.");
|
|
248
|
-
throw error;
|
|
249
|
-
}
|
|
304
|
+
const text = await boundedResponseText(response);
|
|
250
305
|
return JSON.stringify(parseOpenVikingResult(text), null, 2);
|
|
251
306
|
}
|
|
252
307
|
export async function queryDomainKnowledge({ workspace, query, remote = false, env = process.env, }) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { accessSync,
|
|
3
|
+
import { accessSync, existsSync, lstatSync, realpathSync, } from "node:fs";
|
|
4
4
|
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { CAPABILITY_IDS } from "./capability-schema.js";
|
|
7
7
|
import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
|
|
8
|
+
import { readStableFile } from "./stable-file.js";
|
|
8
9
|
const MAX_INPUT_BYTES = 4 * 1024 * 1024;
|
|
9
10
|
const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
10
11
|
const MAX_REPETITIONS = 1_000;
|
|
@@ -37,30 +38,22 @@ function assertRealDirectory(path, label) {
|
|
|
37
38
|
}
|
|
38
39
|
function readBoundedJson(path, label) {
|
|
39
40
|
const absolute = resolve(path);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
throw new Error(`${label} must be a regular non-symlink file.`);
|
|
43
|
-
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
|
44
|
-
const descriptor = openSync(absolute, flags);
|
|
41
|
+
const { bytes } = readStableFile(absolute, { label, maxBytes: MAX_INPUT_BYTES });
|
|
42
|
+
let text;
|
|
45
43
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (bytes.byteLength > MAX_INPUT_BYTES)
|
|
51
|
-
throw new Error(`${label} exceeds the ${MAX_INPUT_BYTES}-byte limit.`);
|
|
52
|
-
let value;
|
|
53
|
-
try {
|
|
54
|
-
value = JSON.parse(bytes.toString("utf8"));
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
throw new Error(`${label} is not valid JSON.`);
|
|
58
|
-
}
|
|
59
|
-
return { path: realpathSync(absolute), value, bytes };
|
|
44
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`${label} contains invalid UTF-8.`);
|
|
60
48
|
}
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`${label} is not valid JSON.`);
|
|
63
55
|
}
|
|
56
|
+
return { path: realpathSync(absolute), value, bytes };
|
|
64
57
|
}
|
|
65
58
|
function git(repository, args, label) {
|
|
66
59
|
const result = spawnSync("git", ["-C", repository, ...args], {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { chmod, copyFile, lstat, mkdtemp, mkdir,
|
|
3
|
+
import { chmod, copyFile, lstat, mkdtemp, mkdir, readdir, realpath, rm, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,6 +11,7 @@ import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./e
|
|
|
11
11
|
import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
|
|
12
12
|
import { applyEvalWorkspaceOverlay, cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
|
|
13
13
|
import { resolveMcpToolProfile } from "./mcp-tool-profile.js";
|
|
14
|
+
import { readStableFile } from "./stable-file.js";
|
|
14
15
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
|
|
16
17
|
let reportSchemaDigestsCache;
|
|
@@ -240,25 +241,30 @@ async function checkpoint(reportStore, report) {
|
|
|
240
241
|
}
|
|
241
242
|
async function collectSnapshotFiles(root) {
|
|
242
243
|
const files = [];
|
|
244
|
+
let totalBytes = 0;
|
|
243
245
|
const walk = async (directory) => {
|
|
244
246
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
245
247
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
246
248
|
const absolute = join(directory, entry.name);
|
|
247
|
-
|
|
248
|
-
if (info.isSymbolicLink())
|
|
249
|
+
if (entry.isSymbolicLink())
|
|
249
250
|
throw new Error("unsafe_note_snapshot");
|
|
250
|
-
if (
|
|
251
|
+
if (entry.isDirectory()) {
|
|
252
|
+
const info = await lstat(absolute);
|
|
253
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
254
|
+
throw new Error("unsafe_note_snapshot");
|
|
251
255
|
await walk(absolute);
|
|
252
256
|
continue;
|
|
253
257
|
}
|
|
254
|
-
if (!
|
|
258
|
+
if (!entry.isFile())
|
|
255
259
|
throw new Error("unsafe_note_snapshot");
|
|
256
|
-
|
|
257
|
-
files.push({ path: relative(root, absolute).split(sep).join("/"), bytes });
|
|
258
|
-
if (files.length > MAX_SNAPSHOT_FILES
|
|
259
|
-
|| files.reduce((total, file) => total + file.bytes.byteLength, 0) > MAX_SNAPSHOT_BYTES) {
|
|
260
|
+
if (files.length >= MAX_SNAPSHOT_FILES) {
|
|
260
261
|
throw new Error("note_snapshot_limit");
|
|
261
262
|
}
|
|
263
|
+
const { bytes } = readStableFile(absolute, { label: "Evaluation note snapshot", maxBytes: MAX_SNAPSHOT_BYTES - totalBytes || 1 });
|
|
264
|
+
files.push({ path: relative(root, absolute).split(sep).join("/"), bytes });
|
|
265
|
+
totalBytes += bytes.byteLength;
|
|
266
|
+
if (totalBytes > MAX_SNAPSHOT_BYTES)
|
|
267
|
+
throw new Error("note_snapshot_limit");
|
|
262
268
|
}
|
|
263
269
|
};
|
|
264
270
|
await walk(root);
|
|
@@ -300,7 +306,8 @@ export async function snapshotTaskNotes(options) {
|
|
|
300
306
|
if (!isContained(root, destination) || destination === root)
|
|
301
307
|
throw new Error("unsafe_note_snapshot");
|
|
302
308
|
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
303
|
-
|
|
309
|
+
// Publish the validated bytes, never reopen the mutable source.
|
|
310
|
+
await writeFile(destination, file.bytes, { flag: "wx", mode: 0o600 });
|
|
304
311
|
await chmod(destination, 0o400);
|
|
305
312
|
manifest.push({ path: file.path, digest: sha256(file.bytes), bytes: file.bytes.byteLength });
|
|
306
313
|
}
|