JuliusBrussee/caveman · error · Error

cave_memory_tenant_invalid

Error message

cave_memory_tenant_invalid

What it means

memoryFilePath() validates the tenant component of the durable memory path against a strict [a-z0-9_-]-class pattern (no dots or path separators) so no tenant value can traverse out of the memory root. The sentinel "_" is reserved for the default tenant. A tenant containing uppercase, slashes, "..", ".json", or other characters throws cave_memory_tenant_invalid.

Source

Thrown at packages/agent/src/memory-store.ts:59

const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,95}$/;

function defaultRoot(): string {
  return process.env.CAVE_AGENT_MEMORY_ROOT ?? join(homedir(), ".caveman", "agent-memory");
}

/**
 * The durable file for (tenant, agentId, namespace). The three scoping
 * components are validated to a `[a-z0-9_-]`-class charset with no `.` or path
 * separator, so no component can traverse out of the memory root.
 */
export function memoryFilePath(
  config: MemoryStoreConfig | undefined,
  agentId: string,
  namespace: string,
): string {
  const tenant = config?.tenant ?? "_";
  if (tenant !== "_" && !TENANT_PATTERN.test(tenant)) {
    throw new Error("cave_memory_tenant_invalid");
  }
  if (!AGENT_PATTERN.test(agentId)) throw new Error("cave_memory_agent_invalid");
  if (!NAMESPACE_PATTERN.test(namespace)) throw new Error("cave_memory_namespace_invalid");
  return join(config?.root ?? defaultRoot(), tenant, agentId, `${namespace}.json`);
}

function isMemoryEntry(value: unknown): value is MemoryEntry {
  return value !== null && typeof value === "object" &&
    typeof (value as { text?: unknown }).text === "string" &&
    Number.isSafeInteger((value as { createdAt?: unknown }).createdAt);
}

/** Read the durable entries. A missing or corrupt file is an empty store, never a throw into a run. */
export async function readMemories(filePath: string): Promise<MemoryEntry[]> {
  try {
    const parsed: unknown = JSON.parse(await readFile(filePath, "utf8"));
    return Array.isArray(parsed) ? parsed.filter(isMemoryEntry) : [];
  } catch {

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Slugify the tenant before configuring the store: lowercase, replace everything outside [a-z0-9_-] with "-"
  2. Use the reserved "_" only for the shared/default tenant, never as a real tenant id
  3. Omit tenant in MemoryStoreConfig when you want the default
  4. Add a unit test asserting your tenant ids match /^[a-z0-9_-]+$/ at the API boundary

Example fix

// before
const store = memoryStore({ tenant: account.organizationName }); // "Acme Corp!"

// after
const slug = account.organizationName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "_";
const store = memoryStore({ tenant: slug });
Defensive patterns

Strategy: validation

Validate before calling

const TENANT_RE = /^[a-z0-9_-]+$/;
function tenantSlug(raw: string): string {
  const slug = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
  if (slug === "" || !TENANT_RE.test(slug)) throw new Error(`cannot derive safe tenant id from ${JSON.stringify(raw)}`);
  return slug;
}

Type guard

const isSafeTenant = (t: string): boolean => t === "_" || /^[a-z0-9_-]+$/.test(t);

Prevention

When it happens

Trigger: Setting config.tenant to values like "Acme Corp", "tenant/../etc", "my.tenant", or "" (empty string is not "_"). Only "_" and pattern-conforming strings pass; omitting tenant config entirely defaults to "_" and never throws.

Common situations: Using a raw hostname, email, or user display name as tenant id; forgetting to slugify tenant ids at system boundaries; or passing null/undefined wrapped as the string "undefined".

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/92b1bac51d7eee72. Report an issue: GitHub.