mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-06 17:57:52 +02:00
fix: harden unified memory boundaries
This commit is contained in:
@@ -9,6 +9,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
|
||||
vault stores portable `ecc.memory.v1` Markdown documents rather than
|
||||
harness-specific transcripts or inboxes.
|
||||
|
||||
## Runtime Prerequisite
|
||||
|
||||
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
|
||||
manual, and Claude plugin installs do not create the required commands on
|
||||
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
|
||||
or MCP examples:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
A repository checkout may instead run the CLI as
|
||||
`node scripts/ecc.js memory ...`, but MCP configurations that name
|
||||
`ecc-memory-mcp` still require that binary on `PATH`.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Save durable context that another agent or later session will need.
|
||||
|
||||
@@ -10,6 +10,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
|
||||
vault stores portable `ecc.memory.v1` Markdown documents rather than
|
||||
harness-specific transcripts or inboxes.
|
||||
|
||||
## Runtime Prerequisite
|
||||
|
||||
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
|
||||
manual, and Claude plugin installs do not create the required commands on
|
||||
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
|
||||
or MCP examples:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
A repository checkout may instead run the CLI as
|
||||
`node scripts/ecc.js memory ...`, but MCP configurations that name
|
||||
`ecc-memory-mcp` still require that binary on `PATH`.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Save durable context that another agent or later session will need.
|
||||
|
||||
@@ -293,6 +293,16 @@ team scopes live under `.ecc/memory/`; user scope lives under
|
||||
use the team scope only for human-inspected, version-controlled sharing. Team
|
||||
memories remain unreviewed context even after they are committed.
|
||||
|
||||
Skill-only, minimal, manual, and Claude plugin installs do not put the Memory
|
||||
Vault runtime on `PATH`. Install the npm runtime separately before using the CLI
|
||||
or optional MCP server:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
```bash
|
||||
# Initialize the project vault.
|
||||
ecc memory init --scope project
|
||||
|
||||
@@ -107,6 +107,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec
|
||||
`ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。
|
||||
该服务默认不会启用。
|
||||
|
||||
仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入
|
||||
`PATH`。请先单独安装 ECC npm 运行时:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制
|
||||
`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务
|
||||
进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享
|
||||
同一个二进制文件和记忆库目录,但不能共用同一个服务进程。
|
||||
|
||||
## 快速开始
|
||||
|
||||
在 2 分钟内快速上手:
|
||||
|
||||
+16
-5
@@ -54,7 +54,16 @@ Use this as the minimal surface to reproduce the setup without leaking private s
|
||||
|
||||
ECC Memory Vault provides one file-first handoff layer instead of a separate
|
||||
inbox or transcript store for every agent. Initialize it from the repository
|
||||
that the agents share:
|
||||
that the agents share. Skill-only, minimal, manual, and Claude plugin installs
|
||||
do not add the Memory Vault runtime to `PATH`; install it separately first:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
Then initialize the vault:
|
||||
|
||||
```bash
|
||||
ecc memory init --scope project --scope team
|
||||
@@ -68,10 +77,12 @@ unreviewed context; human acceptance means promoting verified knowledge into
|
||||
governed project documentation.
|
||||
|
||||
Hermes can call the CLI directly or use the opt-in `ecc-memory-mcp` stdio
|
||||
server. Claude, Codex, Cursor, and OpenCode can connect to the same server or
|
||||
use the same CLI. Every harness must launch from the same repository working
|
||||
directory or receive identical `ECC_MEMORY_PROJECT_ROOT` and
|
||||
`ECC_MEMORY_USER_ROOT` overrides.
|
||||
server. Harnesses may share the same installed binary and vault storage, but
|
||||
each harness must launch its own server process with its own distinct lowercase
|
||||
`ECC_MEMORY_HARNESS` identity; they must not connect to one shared server
|
||||
process. Every process must launch from the same repository working directory
|
||||
or receive identical `ECC_MEMORY_PROJECT_ROOT` and `ECC_MEMORY_USER_ROOT`
|
||||
overrides.
|
||||
|
||||
A Hermes-to-Codex handoff can be written without putting the body in the
|
||||
process list:
|
||||
|
||||
@@ -177,6 +177,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec
|
||||
`ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。
|
||||
该服务默认不会启用。
|
||||
|
||||
仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入
|
||||
`PATH`。请先单独安装 ECC npm 运行时:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制
|
||||
`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务
|
||||
进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享
|
||||
同一个二进制文件和记忆库目录,但不能共用同一个服务进程。
|
||||
|
||||
## 快速开始
|
||||
|
||||
在 2 分钟内启动并运行:
|
||||
|
||||
@@ -470,6 +470,14 @@
|
||||
"workflow-quality"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:unified-memory",
|
||||
"family": "skill",
|
||||
"description": "Cross-harness memory guidance that requires the separately installed ecc-universal CLI runtime.",
|
||||
"modules": [
|
||||
"skill-unified-memory"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:tdd-workflow",
|
||||
"family": "skill",
|
||||
|
||||
@@ -272,10 +272,40 @@
|
||||
"cost": "medium",
|
||||
"stability": "stable"
|
||||
},
|
||||
{
|
||||
"id": "skill-unified-memory",
|
||||
"kind": "skills",
|
||||
"description": "Single-skill unified-memory guidance; requires the separately installed ecc-universal CLI runtime.",
|
||||
"paths": [
|
||||
"skills/unified-memory"
|
||||
],
|
||||
"targets": [
|
||||
"claude",
|
||||
"claude-project",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"codebuddy",
|
||||
"joycode",
|
||||
"qwen",
|
||||
"zed",
|
||||
"hermes",
|
||||
"openclaw",
|
||||
"kimi"
|
||||
],
|
||||
"dependencies": [
|
||||
"platform-configs"
|
||||
],
|
||||
"defaultInstall": false,
|
||||
"cost": "light",
|
||||
"stability": "stable"
|
||||
},
|
||||
{
|
||||
"id": "workflow-quality",
|
||||
"kind": "skills",
|
||||
"description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path.",
|
||||
"description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path. The unified-memory workflow requires the separately installed ecc-universal CLI runtime.",
|
||||
"paths": [
|
||||
"skills/agent-sort",
|
||||
"skills/agent-introspection-debugging",
|
||||
@@ -297,7 +327,6 @@
|
||||
"skills/skill-stocktake",
|
||||
"skills/strategic-compact",
|
||||
"skills/tdd-workflow",
|
||||
"skills/unified-memory",
|
||||
"skills/verification-loop",
|
||||
"skills/windows-desktop-e2e",
|
||||
"skills/agent-self-evaluation",
|
||||
@@ -338,7 +367,7 @@
|
||||
"kimi"
|
||||
],
|
||||
"dependencies": [
|
||||
"platform-configs"
|
||||
"skill-unified-memory"
|
||||
],
|
||||
"defaultInstall": true,
|
||||
"cost": "medium",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"framework-language",
|
||||
"database",
|
||||
"workflow-quality",
|
||||
"skill-unified-memory",
|
||||
"security",
|
||||
"research-apis",
|
||||
"business-content",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"ecc-memory-vault": {
|
||||
"command": "ecc-memory-mcp",
|
||||
"env": {
|
||||
"ECC_MEMORY_HARNESS": "REPLACE_WITH_LOWERCASE_HARNESS_SLUG"
|
||||
"ECC_MEMORY_HARNESS": "YOUR_LOWERCASE_HARNESS_SLUG_HERE"
|
||||
},
|
||||
"description": "Opt-in local ECC Memory Vault shared by Claude, Codex, Hermes, Cursor, OpenCode, and other MCP clients. Replace ECC_MEMORY_HARNESS with this server's lowercase identity; callers cannot override it. Normal search recall is active project+team memory. To permit explicitly requested user scope, the operator may also set ECC_MEMORY_ALLOW_USER_SCOPE=1. Writes are create-only and always unreviewed. Install ECC globally or make its bin available on PATH. Not enabled by default."
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { TextDecoder } = require('util');
|
||||
|
||||
const MEMORY_SCHEMA_VERSION = 'ecc.memory.v1';
|
||||
const MEMORY_KINDS = Object.freeze([
|
||||
'context',
|
||||
@@ -42,6 +44,7 @@ const FRONTMATTER_FIELDS = Object.freeze([
|
||||
['updated_at', 'updatedAt'],
|
||||
]);
|
||||
const FRONTMATTER_KEYS = new Map(FRONTMATTER_FIELDS);
|
||||
const FATAL_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
const SECRET_PATTERNS = Object.freeze([
|
||||
{ label: 'provider API key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/i },
|
||||
@@ -208,6 +211,14 @@ function serializeMemoryDocument(memory) {
|
||||
return `---\n${metadata}\n---${body}\n`;
|
||||
}
|
||||
|
||||
function decodeUtf8(buffer, label = 'text') {
|
||||
try {
|
||||
return FATAL_UTF8_DECODER.decode(buffer);
|
||||
} catch {
|
||||
throw new Error(`${label} must contain valid UTF-8 text.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatterLine(line, sourcePath, seen) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
@@ -230,19 +241,24 @@ function parseFrontmatterLine(line, sourcePath, seen) {
|
||||
}
|
||||
|
||||
function parseMemoryDocument(source, sourcePath = '<memory>') {
|
||||
if (typeof source !== 'string' || !source.startsWith('---\n')) {
|
||||
const openingMarker = typeof source === 'string'
|
||||
? /^---\r?\n/.exec(source)
|
||||
: null;
|
||||
if (!openingMarker) {
|
||||
throw new Error(`Memory document ${sourcePath} must start with --- frontmatter.`);
|
||||
}
|
||||
if (Buffer.byteLength(source, 'utf8') > MAX_DOCUMENT_BYTES) {
|
||||
throw new Error(`Memory document ${sourcePath} is too large.`);
|
||||
}
|
||||
|
||||
const closingIndex = source.indexOf('\n---', 4);
|
||||
if (closingIndex < 0) {
|
||||
const frontmatterStart = openingMarker[0].length;
|
||||
const remainder = source.slice(frontmatterStart);
|
||||
const closingMarker = /\r?\n---(?=\r?\n|$)/.exec(remainder);
|
||||
if (!closingMarker) {
|
||||
throw new Error(`Memory document ${sourcePath} has no closing frontmatter marker.`);
|
||||
}
|
||||
|
||||
const frontmatterSource = source.slice(4, closingIndex);
|
||||
const frontmatterSource = remainder.slice(0, closingMarker.index);
|
||||
const parsed = frontmatterSource.split(/\r?\n/).reduce((state, line) => {
|
||||
const next = parseFrontmatterLine(line, sourcePath, state.seen);
|
||||
return {
|
||||
@@ -258,7 +274,7 @@ function parseMemoryDocument(source, sourcePath = '<memory>') {
|
||||
throw new Error(`Memory document ${sourcePath} is missing fields: ${missing.join(', ')}.`);
|
||||
}
|
||||
|
||||
const afterMarker = source.slice(closingIndex + 4);
|
||||
const afterMarker = remainder.slice(closingMarker.index + closingMarker[0].length);
|
||||
const body = afterMarker.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||
return normalizeMemory({ ...parsed.values, body });
|
||||
}
|
||||
@@ -280,6 +296,7 @@ module.exports = {
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
asNonEmptyString,
|
||||
decodeUtf8,
|
||||
findPotentialSecrets,
|
||||
hasUnsafeControlCharacters,
|
||||
normalizeMemory,
|
||||
|
||||
+38
-13
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
|
||||
const {
|
||||
MAX_BODY_BYTES,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
asNonEmptyString,
|
||||
decodeUtf8,
|
||||
findPotentialSecrets,
|
||||
hasUnsafeControlCharacters,
|
||||
normalizeMemory,
|
||||
@@ -35,7 +36,7 @@ const MAX_QUERY_CHARS = 500;
|
||||
const MAX_RESULTS = 100;
|
||||
const PROJECT_MEMORY_GITIGNORE = '*\n!.gitignore\n';
|
||||
|
||||
const VAULT_ROOT_POLICIES = new WeakMap();
|
||||
const VAULT_ROOT_BOUNDARIES = Symbol('vaultRootBoundaries');
|
||||
|
||||
function findNearestProjectRoot(cwd) {
|
||||
let current = path.resolve(cwd);
|
||||
@@ -74,23 +75,38 @@ function resolveVaultRoots(options = {}) {
|
||||
team: path.join(projectVault, 'team'),
|
||||
user: userVault,
|
||||
};
|
||||
VAULT_ROOT_POLICIES.set(roots, {
|
||||
project: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot,
|
||||
team: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot,
|
||||
user: env.ECC_MEMORY_USER_ROOT ? null : homeDir,
|
||||
Object.defineProperty(roots, VAULT_ROOT_BOUNDARIES, {
|
||||
value: Object.freeze({
|
||||
project: env.ECC_MEMORY_PROJECT_ROOT
|
||||
? realpathNearestExisting(projectVault)
|
||||
: projectRoot,
|
||||
team: env.ECC_MEMORY_PROJECT_ROOT
|
||||
? realpathNearestExisting(projectVault)
|
||||
: projectRoot,
|
||||
user: env.ECC_MEMORY_USER_ROOT
|
||||
? realpathNearestExisting(userVault)
|
||||
: homeDir,
|
||||
}),
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
return roots;
|
||||
return Object.freeze(roots);
|
||||
}
|
||||
|
||||
function assertMemoryRootSafe(roots, scope) {
|
||||
if (!roots || typeof roots !== 'object' || Array.isArray(roots)) {
|
||||
throw new Error('Memory roots must include a trusted boundary policy.');
|
||||
}
|
||||
const root = roots[scope];
|
||||
if (typeof root !== 'string' || root.length === 0) {
|
||||
throw new Error(`No memory root is configured for scope "${scope}".`);
|
||||
}
|
||||
const boundary = VAULT_ROOT_POLICIES.get(roots)?.[scope];
|
||||
if (boundary) {
|
||||
assertWithinTrustedRoot(root, boundary, 'access memory through a symlink');
|
||||
const boundary = roots[VAULT_ROOT_BOUNDARIES]?.[scope];
|
||||
if (typeof boundary !== 'string' || boundary.length === 0) {
|
||||
throw new Error(`No trusted boundary policy is configured for memory scope "${scope}".`);
|
||||
}
|
||||
assertWithinTrustedRoot(root, boundary, 'access memory through a symlink');
|
||||
if (fs.existsSync(root) && fs.lstatSync(root).isSymbolicLink()) {
|
||||
throw new Error(`Refusing to access memory through symlink root: ${root}`);
|
||||
}
|
||||
@@ -152,7 +168,7 @@ function readRegularTextFile(filePath, options = {}) {
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`${label} is too large (maximum ${maxBytes} bytes).`);
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8');
|
||||
return decodeUtf8(Buffer.concat(chunks, total), label);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
@@ -254,7 +270,7 @@ function initializeVault(options = {}) {
|
||||
return directory;
|
||||
});
|
||||
});
|
||||
return { scopes, roots: { ...roots }, directories };
|
||||
return { scopes, roots, directories };
|
||||
}
|
||||
|
||||
function defaultMemoryId(now = new Date()) {
|
||||
@@ -646,6 +662,11 @@ function readMemoryById(id, options = {}) {
|
||||
.filter(entry => entry.memory.links.includes(memoryId))
|
||||
.filter(entry => entry.memory.status === 'active')
|
||||
.map(entry => entry.memory)
|
||||
.filter(memory => (
|
||||
!targetHarness
|
||||
|| memory.targetHarnesses.includes('all')
|
||||
|| memory.targetHarnesses.includes(targetHarness)
|
||||
))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const backlinks = allBacklinks
|
||||
.slice(0, MAX_RESULTS)
|
||||
@@ -695,7 +716,7 @@ function doctorMemoryVault(options = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const brokenLinks = allBrokenLinks
|
||||
const brokenLinks = [...allBrokenLinks]
|
||||
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
||||
const ok = loaded.invalidFileCount === 0
|
||||
&& allDuplicateIds.length === 0
|
||||
@@ -726,15 +747,19 @@ function doctorMemoryVault(options = {}) {
|
||||
module.exports = {
|
||||
DEFAULT_RECALL_SCOPES,
|
||||
MAX_BODY_BYTES,
|
||||
MAX_DIAGNOSTICS,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MAX_FILES,
|
||||
MAX_QUERY_CHARS,
|
||||
MAX_RESULTS,
|
||||
MAX_SCAN_BYTES,
|
||||
MEMORY_KINDS,
|
||||
MEMORY_SCHEMA_VERSION,
|
||||
MEMORY_SCOPES,
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
defaultMemoryId,
|
||||
decodeUtf8,
|
||||
doctorMemoryVault,
|
||||
findPotentialSecrets,
|
||||
findNearestProjectRoot,
|
||||
|
||||
@@ -187,11 +187,6 @@ function assertScopesAuthorized(scopes, security) {
|
||||
return requestedScopes;
|
||||
}
|
||||
|
||||
function isTargetVisible(memory, harness) {
|
||||
return memory.targetHarnesses.includes('all')
|
||||
|| memory.targetHarnesses.includes(harness);
|
||||
}
|
||||
|
||||
function textResult(payload) {
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
@@ -307,7 +302,7 @@ function executeMemoryTool(name, rawArguments, options = {}) {
|
||||
});
|
||||
return textResult({
|
||||
memory: read.memory,
|
||||
backlinks: read.backlinks.filter(memory => isTargetVisible(memory, security.harness)),
|
||||
backlinks: read.backlinks,
|
||||
backlinksTruncated: read.backlinksTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
+37
-3
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
|
||||
const {
|
||||
MAX_BODY_BYTES,
|
||||
decodeUtf8,
|
||||
doctorMemoryVault,
|
||||
initializeVault,
|
||||
readMemoryById,
|
||||
@@ -36,6 +37,9 @@ const BOOLEAN_OPTIONS = new Map([
|
||||
['--json', 'json'],
|
||||
['--stdin', 'stdin'],
|
||||
]);
|
||||
const DEFAULT_STDIN_RETRY_DELAY_MS = 10;
|
||||
const MAX_STDIN_RETRY_WAIT_MS = 5_000;
|
||||
const STDIN_RETRY_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function usage() {
|
||||
return `
|
||||
@@ -140,12 +144,42 @@ function oneValue(values, label, fallback = null) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
function readBoundedStdin(maxBytes) {
|
||||
function waitForStdinRetry(milliseconds) {
|
||||
Atomics.wait(STDIN_RETRY_SIGNAL, 0, 0, milliseconds);
|
||||
}
|
||||
|
||||
function readBoundedStdin(maxBytes, retryOptions = {}) {
|
||||
const retryDelayMs = Number.isInteger(retryOptions.retryDelayMs)
|
||||
&& retryOptions.retryDelayMs > 0
|
||||
? retryOptions.retryDelayMs
|
||||
: DEFAULT_STDIN_RETRY_DELAY_MS;
|
||||
const maxRetryWaitMs = Number.isInteger(retryOptions.maxRetryWaitMs)
|
||||
&& retryOptions.maxRetryWaitMs >= 0
|
||||
? retryOptions.maxRetryWaitMs
|
||||
: MAX_STDIN_RETRY_WAIT_MS;
|
||||
const wait = typeof retryOptions.wait === 'function'
|
||||
? retryOptions.wait
|
||||
: waitForStdinRetry;
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let remainingRetryWaitMs = maxRetryWaitMs;
|
||||
while (total <= maxBytes) {
|
||||
const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
|
||||
const bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
|
||||
let bytesRead;
|
||||
try {
|
||||
bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
|
||||
} catch (error) {
|
||||
const retryable = ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error?.code);
|
||||
if (!retryable) throw error;
|
||||
if (remainingRetryWaitMs < retryDelayMs) {
|
||||
throw new Error(
|
||||
`Standard input remained unavailable after ${maxRetryWaitMs}ms.`
|
||||
);
|
||||
}
|
||||
wait(retryDelayMs);
|
||||
remainingRetryWaitMs -= retryDelayMs;
|
||||
continue;
|
||||
}
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(buffer.subarray(0, bytesRead));
|
||||
total += bytesRead;
|
||||
@@ -153,7 +187,7 @@ function readBoundedStdin(maxBytes) {
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`memory body is too large (maximum ${maxBytes} bytes).`);
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8');
|
||||
return decodeUtf8(Buffer.concat(chunks, total), 'memory body from standard input');
|
||||
}
|
||||
|
||||
function readBody(options) {
|
||||
|
||||
@@ -11,6 +11,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
|
||||
vault stores portable `ecc.memory.v1` Markdown documents rather than
|
||||
harness-specific transcripts or inboxes.
|
||||
|
||||
## Runtime Prerequisite
|
||||
|
||||
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
|
||||
manual, and Claude plugin installs do not create the required commands on
|
||||
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
|
||||
or MCP examples:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal
|
||||
ecc memory --help
|
||||
command -v ecc-memory-mcp
|
||||
```
|
||||
|
||||
A repository checkout may instead run the CLI as
|
||||
`node scripts/ecc.js memory ...`, but MCP configurations that name
|
||||
`ecc-memory-mcp` still require that binary on `PATH`.
|
||||
|
||||
## When To Use
|
||||
|
||||
- Save durable context that another agent or later session will need.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const SKILL_PATHS = [
|
||||
'skills/unified-memory/SKILL.md',
|
||||
'.agents/skills/unified-memory/SKILL.md',
|
||||
'.cursor/skills/unified-memory/SKILL.md',
|
||||
];
|
||||
const RUNTIME_DOC_PATHS = [
|
||||
...SKILL_PATHS,
|
||||
'README.md',
|
||||
'docs/HERMES-SETUP.md',
|
||||
'README.zh-CN.md',
|
||||
'docs/zh-CN/README.md',
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function stripFrontmatter(source) {
|
||||
return source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
||||
}
|
||||
|
||||
console.log('\n=== Testing unified-memory install and adapter surfaces ===\n');
|
||||
|
||||
test('documents the separately installed ECC runtime on every exposed surface', () => {
|
||||
for (const relativePath of RUNTIME_DOC_PATHS) {
|
||||
const source = read(relativePath);
|
||||
assert.match(
|
||||
source,
|
||||
/npm install -g ecc-universal/i,
|
||||
`${relativePath} must state how to install the required CLI runtime`
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/ecc-memory-mcp/,
|
||||
`${relativePath} must identify the optional MCP binary`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps harness-specific unified-memory skill bodies in sync', () => {
|
||||
const bodies = SKILL_PATHS.map(relativePath => stripFrontmatter(read(relativePath)));
|
||||
assert.strictEqual(bodies[1], bodies[0], `${SKILL_PATHS[1]} body drifted`);
|
||||
assert.strictEqual(bodies[2], bodies[0], `${SKILL_PATHS[2]} body drifted`);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -168,6 +168,27 @@ function runTests() {
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('marks unified-memory install surfaces as requiring the separate ECC runtime', () => {
|
||||
const component = getInstallComponent('skill:unified-memory');
|
||||
assert.deepStrictEqual(component.moduleIds, ['skill-unified-memory']);
|
||||
assert.match(component.description, /ecc-universal/i);
|
||||
assert.match(component.description, /separate|external|runtime/i);
|
||||
|
||||
const modules = listInstallModules();
|
||||
const singleSkillModule = modules.find(module => module.id === 'skill-unified-memory');
|
||||
const workflowModule = modules.find(module => module.id === 'workflow-quality');
|
||||
assert.ok(singleSkillModule, 'Should define an explicit unified-memory module');
|
||||
assert.match(singleSkillModule.description, /ecc-universal/i);
|
||||
assert.match(workflowModule.description, /ecc-universal/i);
|
||||
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['skill:unified-memory'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('skill-unified-memory'));
|
||||
assert.ok(plan.selectedModuleIds.includes('platform-configs'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists supported legacy compatibility languages', () => {
|
||||
const languages = listLegacyCompatibilityLanguages();
|
||||
assert.ok(languages.includes('typescript'));
|
||||
@@ -230,7 +251,14 @@ function runTests() {
|
||||
|
||||
assert.deepStrictEqual(
|
||||
plan.selectedModuleIds,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.ok(plan.skippedModuleIds.includes('hooks-runtime'));
|
||||
assert.ok(!plan.skippedModuleIds.includes('platform-configs'));
|
||||
@@ -248,7 +276,14 @@ function runTests() {
|
||||
|
||||
assert.deepStrictEqual(
|
||||
plan.selectedModuleIds,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.ok(!plan.selectedModuleIds.includes('hooks-runtime'),
|
||||
'minimal profile should not install hooks-runtime');
|
||||
@@ -265,7 +300,14 @@ function runTests() {
|
||||
|
||||
assert.deepStrictEqual(
|
||||
plan.selectedModuleIds,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(plan.skippedModuleIds, []);
|
||||
assert.strictEqual(plan.targetAdapterId, 'qwen-home');
|
||||
@@ -290,7 +332,14 @@ function runTests() {
|
||||
|
||||
assert.deepStrictEqual(
|
||||
plan.selectedModuleIds,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(plan.skippedModuleIds, []);
|
||||
assert.strictEqual(plan.targetAdapterId, 'zed-project');
|
||||
|
||||
@@ -103,5 +103,12 @@ test('rejects terminal and bidirectional control characters', () => {
|
||||
assertRejected(representativeMemory({ body: ' \n\t' }), 'pattern');
|
||||
});
|
||||
|
||||
test('accepts newlines, tabs, and carriage returns inside a non-empty Markdown body', () => {
|
||||
const memory = representativeMemory({
|
||||
body: 'Line one\n\n- item\twith tab\r\nLine two',
|
||||
});
|
||||
assert.strictEqual(validateMemory(memory), true, JSON.stringify(validateMemory.errors));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
+113
-11
@@ -7,6 +7,9 @@ const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
MAX_DIAGNOSTICS,
|
||||
MAX_FILES,
|
||||
MAX_SCAN_BYTES,
|
||||
MEMORY_SCHEMA_VERSION,
|
||||
MEMORY_KINDS,
|
||||
doctorMemoryVault,
|
||||
@@ -169,6 +172,21 @@ test('round-trips the strict ecc.memory.v1 Markdown frontmatter contract', () =>
|
||||
assert.deepStrictEqual(parseMemoryDocument(serialized, 'handoff.md'), original);
|
||||
});
|
||||
|
||||
test('accepts CRLF frontmatter delimiters and line endings', () => {
|
||||
const original = baseMemory();
|
||||
const serialized = serializeMemoryDocument(original).replace(/\n/g, '\r\n');
|
||||
assert.deepStrictEqual(parseMemoryDocument(serialized, 'windows.md'), original);
|
||||
});
|
||||
|
||||
test('requires the closing frontmatter marker to occupy an exact delimiter line', () => {
|
||||
const malformed = serializeMemoryDocument(baseMemory())
|
||||
.replace('\n---\n\n', '\n---NOT-A-DELIMITER\n\n');
|
||||
assert.throws(
|
||||
() => parseMemoryDocument(malformed, 'malformed-closing.md'),
|
||||
/closing frontmatter|frontmatter line/i
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed, unknown-schema, and invalid metadata documents', () => {
|
||||
assert.throws(() => parseMemoryDocument('not frontmatter', 'bad.md'), /frontmatter/i);
|
||||
assert.throws(
|
||||
@@ -249,6 +267,30 @@ test('never overwrites a duplicate ID', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('never follows a pre-existing destination symlink during create-only publication', () => {
|
||||
const fixture = createFixture();
|
||||
const outside = path.join(fixture.root, 'outside.md');
|
||||
try {
|
||||
const notes = path.join(fixture.roots.project, 'notes');
|
||||
fs.mkdirSync(notes, { recursive: true });
|
||||
fs.writeFileSync(outside, 'outside sentinel');
|
||||
const destination = path.join(notes, 'mem_20260726_01kexample.md');
|
||||
fs.symlinkSync(outside, destination);
|
||||
|
||||
assert.throws(
|
||||
() => saveMemory(
|
||||
{ title: 'Must not overwrite', body: 'create-only content' },
|
||||
fixedOptions(fixture.roots)
|
||||
),
|
||||
/already exists|create-only|outside|refusing/i
|
||||
);
|
||||
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside sentinel');
|
||||
assert.strictEqual(fs.lstatSync(destination).isSymbolicLink(), true);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the project memory gitignore is preseeded with unsafe rules', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
@@ -327,6 +369,48 @@ test('rejects a vault path that traverses a symlink before creating directories'
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a symlinked ancestor when roots come back from initializeVault', () => {
|
||||
const fixture = createFixture();
|
||||
const outside = path.join(fixture.root, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
try {
|
||||
const initialized = initializeVault({ roots: fixture.roots, scopes: ['project'] });
|
||||
fs.rmSync(path.join(fixture.projectRoot, '.ecc'), { recursive: true, force: true });
|
||||
fs.symlinkSync(outside, path.join(fixture.projectRoot, '.ecc'));
|
||||
|
||||
assert.throws(
|
||||
() => saveMemory(
|
||||
{ title: 'Escaped note', body: 'must stay in the project' },
|
||||
{ ...fixedOptions(fixture.roots), roots: initialized.roots }
|
||||
),
|
||||
/symlink|outside|trusted/i
|
||||
);
|
||||
assert.strictEqual(fs.existsSync(path.join(outside, 'memory')), false);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when callers provide roots without a boundary policy', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const rootsWithoutPolicy = {
|
||||
project: fixture.roots.project,
|
||||
team: fixture.roots.team,
|
||||
user: fixture.roots.user,
|
||||
};
|
||||
assert.throws(
|
||||
() => saveMemory(
|
||||
{ title: 'Untrusted roots', body: 'must not be written' },
|
||||
fixedOptions(rootsWithoutPolicy)
|
||||
),
|
||||
/boundary policy|trusted boundary/i
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects traversal IDs, oversized bodies, NUL bytes, and suspected secrets', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
@@ -449,6 +533,20 @@ test('opens regular text files without following a stable symlink', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed UTF-8 instead of altering durable text', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-utf8-'));
|
||||
const target = path.join(root, 'invalid.md');
|
||||
try {
|
||||
fs.writeFileSync(target, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
|
||||
assert.throws(
|
||||
() => readRegularTextFile(target, { maxBytes: 16 }),
|
||||
/valid UTF-8/i
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('opens a file descriptor before inspecting path metadata', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-open-first-'));
|
||||
const target = path.join(root, 'target.md');
|
||||
@@ -683,7 +781,7 @@ test('doctor caps traversal before an oversized directory can dominate recall',
|
||||
const notes = path.join(fixture.roots.project, 'notes');
|
||||
try {
|
||||
fs.mkdirSync(notes, { recursive: true });
|
||||
for (let index = 0; index < 5001; index += 1) {
|
||||
for (let index = 0; index < MAX_FILES + 1; index += 1) {
|
||||
fs.writeFileSync(path.join(notes, `noise-${index}.txt`), '');
|
||||
}
|
||||
const report = doctorMemoryVault({
|
||||
@@ -702,14 +800,16 @@ test('doctor caps hostile diagnostics and reports total counts', () => {
|
||||
const notes = path.join(fixture.roots.project, 'notes');
|
||||
try {
|
||||
fs.mkdirSync(notes, { recursive: true });
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
const invalidFileTotal = MAX_DIAGNOSTICS + 20;
|
||||
for (let index = 0; index < invalidFileTotal; index += 1) {
|
||||
fs.writeFileSync(path.join(notes, `malformed-${index}.md`), 'not memory');
|
||||
}
|
||||
const missingLinks = Array.from(
|
||||
{ length: 64 },
|
||||
(_, index) => `mem_missing_${String(index).padStart(3, '0')}`
|
||||
);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const linkDocumentCount = Math.ceil((MAX_DIAGNOSTICS + 1) / missingLinks.length);
|
||||
for (let index = 0; index < linkDocumentCount; index += 1) {
|
||||
fs.writeFileSync(
|
||||
path.join(notes, `links-${index}.md`),
|
||||
serializeMemoryDocument(baseMemory({
|
||||
@@ -724,10 +824,10 @@ test('doctor caps hostile diagnostics and reports total counts', () => {
|
||||
roots: fixture.roots,
|
||||
scopes: ['project'],
|
||||
});
|
||||
assert.strictEqual(report.invalidFileCount, 120);
|
||||
assert.strictEqual(report.invalidFiles.length, 100);
|
||||
assert.strictEqual(report.brokenLinkCount, 128);
|
||||
assert.strictEqual(report.brokenLinks.length, 100);
|
||||
assert.strictEqual(report.invalidFileCount, invalidFileTotal);
|
||||
assert.strictEqual(report.invalidFiles.length, MAX_DIAGNOSTICS);
|
||||
assert.strictEqual(report.brokenLinkCount, linkDocumentCount * missingLinks.length);
|
||||
assert.strictEqual(report.brokenLinks.length, MAX_DIAGNOSTICS);
|
||||
assert.strictEqual(report.diagnosticsTruncated, true);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
@@ -739,7 +839,9 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
|
||||
const notes = path.join(fixture.roots.project, 'notes');
|
||||
try {
|
||||
fs.mkdirSync(notes, { recursive: true });
|
||||
for (let index = 0; index < 270; index += 1) {
|
||||
const bodyBytes = 63 * 1024;
|
||||
const fileTotal = Math.ceil(MAX_SCAN_BYTES / bodyBytes) + 2;
|
||||
for (let index = 0; index < fileTotal; index += 1) {
|
||||
const id = `mem_scan_${String(index).padStart(4, '0')}`;
|
||||
fs.writeFileSync(
|
||||
path.join(notes, `${id}.md`),
|
||||
@@ -747,7 +849,7 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
|
||||
id,
|
||||
kind: 'note',
|
||||
links: [],
|
||||
body: 'x'.repeat(63 * 1024),
|
||||
body: 'x'.repeat(bodyBytes),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -756,8 +858,8 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
|
||||
scopes: ['project'],
|
||||
});
|
||||
assert.strictEqual(report.truncated, true);
|
||||
assert.ok(report.scannedBytes <= 16 * 1024 * 1024);
|
||||
assert.ok(report.memoryCount < 270);
|
||||
assert.ok(report.scannedBytes <= MAX_SCAN_BYTES);
|
||||
assert.ok(report.memoryCount < fileTotal);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -365,7 +365,10 @@ function runTests() {
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: core'));
|
||||
assert.ok(result.stdout.includes('Included components: (none)'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality'));
|
||||
assert.ok(result.stdout.includes(
|
||||
'Selected modules: rules-core, agents-core, commands-core, hooks-runtime, '
|
||||
+ 'platform-configs, skill-unified-memory, workflow-quality'
|
||||
));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
@@ -404,7 +407,10 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: minimal'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, platform-configs, workflow-quality'));
|
||||
assert.ok(result.stdout.includes(
|
||||
'Selected modules: rules-core, agents-core, commands-core, platform-configs, '
|
||||
+ 'skill-unified-memory, workflow-quality'
|
||||
));
|
||||
assert.ok(!result.stdout.includes('hooks-runtime'));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
@@ -491,7 +497,14 @@ function runTests() {
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(
|
||||
state.resolution.selectedModules,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.ok(state.resolution.skippedModules.includes('hooks-runtime'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('workflow-quality'));
|
||||
|
||||
@@ -9,6 +9,11 @@ const { PassThrough } = require('stream');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
|
||||
const {
|
||||
MAX_RESULTS,
|
||||
resolveVaultRoots,
|
||||
saveMemory,
|
||||
} = require('../../scripts/lib/memory-vault');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -345,6 +350,51 @@ async function main() {
|
||||
});
|
||||
});
|
||||
|
||||
await test('filters harness-visible backlinks before applying the response cap', async () => {
|
||||
await withClient(async (client, fixture) => {
|
||||
const roots = resolveVaultRoots({
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
});
|
||||
const saveWithId = (input, id) => saveMemory(input, {
|
||||
roots,
|
||||
now: () => '2026-07-26T20:00:00.000Z',
|
||||
idFactory: () => id,
|
||||
});
|
||||
const targetId = 'mem_backlink_target';
|
||||
saveWithId({
|
||||
title: 'Backlink target',
|
||||
body: 'Visible target body.',
|
||||
targetHarnesses: ['claude'],
|
||||
}, targetId);
|
||||
|
||||
for (let index = 0; index < MAX_RESULTS; index += 1) {
|
||||
saveWithId({
|
||||
title: `Hidden backlink ${index}`,
|
||||
body: 'Only Hermes may see this backlink.',
|
||||
targetHarnesses: ['hermes'],
|
||||
links: [targetId],
|
||||
}, `mem_backlink_hidden_${String(index).padStart(3, '0')}`);
|
||||
}
|
||||
saveWithId({
|
||||
title: 'Visible backlink',
|
||||
body: 'Claude must still receive this backlink.',
|
||||
targetHarnesses: ['claude'],
|
||||
links: [targetId],
|
||||
}, 'mem_backlink_visible_zzz');
|
||||
|
||||
const read = parseTextResult(await client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: { id: targetId },
|
||||
}));
|
||||
assert.deepStrictEqual(
|
||||
read.backlinks.map(memory => memory.id),
|
||||
['mem_backlink_visible_zzz']
|
||||
);
|
||||
assert.strictEqual(read.backlinksTruncated, false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('denies user scope unless the server explicitly grants it', async () => {
|
||||
await withClient(async client => {
|
||||
await assert.rejects(
|
||||
@@ -429,7 +479,8 @@ async function main() {
|
||||
env: fixture.env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notStrictEqual(started.status, 0);
|
||||
assert.strictEqual(started.error, undefined);
|
||||
assert.strictEqual(started.status, 1);
|
||||
assert.match(started.stderr, /ECC_MEMORY_HARNESS/);
|
||||
assert.ok(!started.stderr.includes('\n at '));
|
||||
} finally {
|
||||
|
||||
@@ -8,7 +8,7 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const MEMORY_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'memory.js');
|
||||
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||
const { sanitizeTerminalText } = require(MEMORY_SCRIPT);
|
||||
const { readBoundedStdin, sanitizeTerminalText } = require(MEMORY_SCRIPT);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -103,6 +103,68 @@ test('routes stdin through ecc memory without dropping the body', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('retries transient stdin EAGAIN without busy-spinning and preserves byte bounds', () => {
|
||||
const originalReadSync = fs.readSync;
|
||||
let readCalls = 0;
|
||||
let waitCalls = 0;
|
||||
try {
|
||||
fs.readSync = (_descriptor, buffer) => {
|
||||
readCalls += 1;
|
||||
if (readCalls <= 2) {
|
||||
const error = new Error('temporarily unavailable');
|
||||
error.code = 'EAGAIN';
|
||||
throw error;
|
||||
}
|
||||
if (readCalls === 3) {
|
||||
buffer.write('ready');
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
assert.strictEqual(readBoundedStdin(8, {
|
||||
retryDelayMs: 1,
|
||||
maxRetryWaitMs: 4,
|
||||
wait: () => {
|
||||
waitCalls += 1;
|
||||
},
|
||||
}), 'ready');
|
||||
assert.strictEqual(readCalls, 4);
|
||||
assert.strictEqual(waitCalls, 2);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds persistent stdin EAGAIN retries instead of waiting forever', () => {
|
||||
const originalReadSync = fs.readSync;
|
||||
let readCalls = 0;
|
||||
let waitCalls = 0;
|
||||
try {
|
||||
fs.readSync = () => {
|
||||
readCalls += 1;
|
||||
const error = new Error('temporarily unavailable');
|
||||
error.code = 'EAGAIN';
|
||||
throw error;
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => readBoundedStdin(8, {
|
||||
retryDelayMs: 1,
|
||||
maxRetryWaitMs: 3,
|
||||
wait: () => {
|
||||
waitCalls += 1;
|
||||
},
|
||||
}),
|
||||
/standard input remained unavailable/i
|
||||
);
|
||||
assert.strictEqual(readCalls, 4);
|
||||
assert.strictEqual(waitCalls, 3);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('initializes selected scopes and reports their roots as JSON', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
@@ -310,6 +372,20 @@ test('rejects ambiguous body sources and does not expose a trust promotion flag'
|
||||
], fixture, { input: ' \n\t' });
|
||||
assert.notStrictEqual(empty.status, 0);
|
||||
assert.ok(empty.stderr.includes('non-whitespace context'));
|
||||
|
||||
const invalidUtf8Body = path.join(fixture.root, 'invalid-utf8.md');
|
||||
fs.writeFileSync(invalidUtf8Body, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
|
||||
const invalidUtf8 = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Invalid UTF-8',
|
||||
'--body-file', invalidUtf8Body,
|
||||
], fixture);
|
||||
assert.notStrictEqual(invalidUtf8.status, 0);
|
||||
assert.match(invalidUtf8.stderr, /valid UTF-8/i);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user