Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Memory/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ When `storage.token`, `MEMMY_MEMORY_TOKEN`, or `MEMORY_SERVICE_TOKEN` is set,
all HTTP routes except `GET /api/v1/health` require that token as a bearer token
or `x-api-key`.

### Per-agent scoped API keys

Installations that need per-agent credentials can configure
`storage.scopedApiKeys` without a storage token. Each map key is the API key
itself; `namespace.source` and `namespace.profileId` are required, and
`scopes` defaults to read and write (`memory:read`, `memory:write`) when
omitted:

```yaml
memmyMemory:
storage:
scopedApiKeys:
"agent-a-readonly-key":
namespace:
source: agent-a
profileId: default
scopes: [memory:read]
```

Requests authenticate with the key as a bearer token or `x-api-key`. A key
whose scopes do not cover a route is rejected with `403`, and requests without
a valid key are rejected with `401`: while any scoped key is configured the
service never falls back to anonymous access. A storage token, when also
configured, keeps full `*` access and takes precedence. Scoped API keys
restrict access at the service HTTP boundary only; they are not an OS-level
security boundary for local agents that can read the config file.

## CLI

Run the CLI directly from source inside the `Memory/` directory:
Expand Down
63 changes: 61 additions & 2 deletions Memory/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type RuntimeModelCatalog
} from "../contracts/index.js";
import { resolveTimeZone } from "../utils/time.js";
import type { RuntimeNamespace } from "../types.js";

export type LlmProviderName =
| ""
Expand Down Expand Up @@ -105,6 +106,13 @@ export interface StorageConfig {
sqlitePath?: string;
endpoint?: string;
token?: string;
/** Per-agent API keys accepted by the packaged service entry. Map key is the API key itself. */
scopedApiKeys?: Record<string, ScopedApiKeyConfig>;
}

export interface ScopedApiKeyConfig {
namespace: RuntimeNamespace;
scopes?: string[];
}

export interface AgentAccessConfig {
Expand Down Expand Up @@ -323,7 +331,8 @@ export const DEFAULT_MEMMY_CONFIG: MemmyConfig = {
backend: "sqlite",
sqlitePath: defaultMemoryDatabasePath(),
endpoint: "http://127.0.0.1:18960",
token: undefined
token: undefined,
scopedApiKeys: undefined
},
summary: {
provider: "",
Expand Down Expand Up @@ -752,10 +761,60 @@ function normalizeStorage(input: Record<string, unknown>): StorageConfig {
backend: storageBackend(input.backend, DEFAULT_MEMMY_CONFIG.storage.backend),
sqlitePath: optionalPathString(input.sqlitePath),
endpoint: optionalString(input.endpoint) ?? DEFAULT_MEMMY_CONFIG.storage.endpoint,
token: optionalString(input.token)
token: optionalString(input.token),
scopedApiKeys: normalizeScopedApiKeys(input.scopedApiKeys)
};
}

/**
* Parses `memmyMemory.storage.scopedApiKeys`. An entry is kept only when its
* namespace carries a non-empty `source` and `profileId`; malformed entries are
* dropped so an unusable key fails closed with 401 instead of granting access.
* A record that is present but entirely malformed still normalizes to an empty
* record so the entry point never falls back to anonymous access.
*/
function normalizeScopedApiKeys(input: unknown): StorageConfig["scopedApiKeys"] {
if (!isRecord(input)) return undefined;
const scopedApiKeys: Record<string, ScopedApiKeyConfig> = {};
for (const [key, value] of Object.entries(input)) {
if (!key.trim() || !isRecord(value)) continue;
const namespace = scopedApiKeyNamespace(value.namespace);
if (!namespace) continue;
const scopes = scopedApiKeyScopes(value.scopes);
scopedApiKeys[key] = {
namespace,
...(scopes ? { scopes } : {})
};
}
return scopedApiKeys;
}

function scopedApiKeyNamespace(input: unknown): RuntimeNamespace | undefined {
if (!isRecord(input)) return undefined;
const source = optionalString(input.source);
const profileId = optionalString(input.profileId);
if (!source || !profileId) return undefined;
return {
source,
profileId,
profileLabel: optionalString(input.profileLabel),
projectId: optionalString(input.projectId),
workspaceId: optionalString(input.workspaceId),
workspacePath: optionalString(input.workspacePath),
sessionKey: optionalString(input.sessionKey),
userId: optionalString(input.userId),
tenantId: optionalString(input.tenantId)
};
}

function scopedApiKeyScopes(input: unknown): string[] | undefined {
if (!Array.isArray(input)) return undefined;
const scopes = input.filter((scope): scope is string =>
typeof scope === "string" && scope.trim().length > 0
);
return scopes.length > 0 ? scopes : undefined;
}

function normalizeLlm(input: Record<string, unknown>, defaults: LlmConfig): LlmConfig {
return {
provider: llmProvider(input.provider, defaults.provider),
Expand Down
29 changes: 24 additions & 5 deletions Memory/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Server } from "node:http";
import { createStorageBackend, type StorageBackend } from "../storage/backend.js";
import { loadMemmyConfig } from "../config/index.js";
import { loadMemmyConfig, type MemmyConfig } from "../config/index.js";
import { createMemoryLogger, memoryErrorFields } from "../logging/logger.js";
import { MemoryService } from "../service/memory-service.js";
import { closeMemoryHttpServer, listenMemoryHttpServer } from "./http.js";
import { closeMemoryHttpServer, listenMemoryHttpServer, type MemoryHttpAuthOptions } from "./http.js";
import { loadCloudServiceEnv } from "../cli/load-env.js";
import { requestMemoryServiceRestart } from "./service-restart.js";
import { MEMORY_PROTOCOL_VERSION, MEMORY_SERVICE_VERSION } from "../version.js";
Expand Down Expand Up @@ -50,6 +50,27 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
}
}

/**
* Builds the HTTP auth options for the packaged service entry. Precedence is
* `localServiceToken` (storage token, scope `*`) over `scopedApiKeys` over
* `allowAnonymous`: anonymous access is only offered when the operator
* configured neither credential type, so a configured scoped key can never
* silently fall back to an open service.
*/
export function memoryServiceAuth(config: MemmyConfig): MemoryHttpAuthOptions {
const auth: MemoryHttpAuthOptions = {};
if (config.storage.token) {
auth.localServiceToken = config.storage.token;
}
if (config.storage.scopedApiKeys) {
auth.scopedApiKeys = config.storage.scopedApiKeys;
}
if (!auth.localServiceToken && !auth.scopedApiKeys) {
auth.allowAnonymous = true;
}
return auth;
}

async function runMemoryService(argv: string[], lifecycle: {
shutdown(): void;
setStopService(stop: () => void): void;
Expand Down Expand Up @@ -113,9 +134,7 @@ async function runMemoryService(argv: string[], lifecycle: {
requestShutdown();
}
}),
auth: config.storage.token
? { localServiceToken: config.storage.token }
: { allowAnonymous: true },
auth: memoryServiceAuth(config),
configPath,
startAgentSourceAutomation: true
});
Expand Down
51 changes: 51 additions & 0 deletions Memory/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,57 @@ describe("memmy memory config", () => {
expect(config.storage.sqlitePath).toBe(join(homedir(), ".memmy", "memory-service", "memory.sqlite"));
});

it("normalizes storage scoped api keys and drops malformed entries", () => {
const root = tempRoot();
const configPath = join(root, "config.yaml");
writeFileSync(configPath, YAML.stringify({
memmyMemory: {
storage: {
endpoint: "http://127.0.0.1:18960",
scopedApiKeys: {
"valid-key": {
namespace: { source: "agent-a", profileId: "default" },
scopes: ["memory:read", 42, " "]
},
"missing-profile": { namespace: { source: "agent-b" } },
"missing-namespace": { scopes: ["memory:read"] },
"empty-namespace": { namespace: {}, scopes: ["memory:read"] },
"scopeless-key": { namespace: { source: "agent-c", profileId: "default" } }
}
}
}
}));

const { config } = loadMemmyConfig(configPath);

expect(config.storage.scopedApiKeys).toEqual({
"valid-key": {
namespace: { source: "agent-a", profileId: "default" },
scopes: ["memory:read"]
},
"scopeless-key": {
namespace: { source: "agent-c", profileId: "default" }
}
});

writeFileSync(configPath, YAML.stringify({
memmyMemory: {
storage: {
endpoint: "http://127.0.0.1:18960",
scopedApiKeys: { "broken-key": { namespace: { source: "agent-d" } } }
}
}
}));
expect(loadMemmyConfig(configPath).config.storage.scopedApiKeys).toEqual({});

writeFileSync(configPath, YAML.stringify({
memmyMemory: {
storage: { endpoint: "http://127.0.0.1:18960" }
}
}));
expect(loadMemmyConfig(configPath).config.storage.scopedApiKeys).toBeUndefined();
});

it("reads user id from memmyMemory config and environment aliases", () => {
const root = tempRoot();
const configPath = join(root, "config.yaml");
Expand Down
119 changes: 119 additions & 0 deletions Memory/tests/server-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,125 @@ describe("memmy memory server entrypoint", () => {
rmSync(root, { recursive: true, force: true });
}
});

it("authorizes configured scoped api keys and enforces their scopes", async () => {
const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-auth-"));
const configPath = join(root, "config.yaml");
writeFileSync(configPath, YAML.stringify({ memmyMemory: {
storage: {
mode: "local",
backend: "sqlite",
sqlitePath: join(root, "memory.sqlite"),
scopedApiKeys: {
"scoped-agent-a-key": {
namespace: { source: "agent-a", profileId: "default" },
scopes: ["memory:read"]
}
}
}
} }));
const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer");
const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]);
try {
const endpoint = await waitForWrittenEndpoint(configPath);
const scopedHeaders = { "x-api-key": "scoped-agent-a-key" };

const readResponse = await fetch(`${endpoint}/api/v1/admin/memory-token-budget`, {
headers: scopedHeaders
});
expect(readResponse.status).toBe(200);

const writeResponse = await fetch(`${endpoint}/api/v1/memory/add`, {
method: "POST",
headers: { ...scopedHeaders, "content-type": "application/json" },
body: "{}"
});
expect(writeResponse.status).toBe(403);
} finally {
listen.mock.calls[0]?.[0].onShutdownRequested?.();
await running;
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
}
});

it("refuses anonymous and unknown-token access when scoped api keys are configured", async () => {
const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-anonymous-"));
const configPath = join(root, "config.yaml");
writeFileSync(configPath, YAML.stringify({ memmyMemory: {
storage: {
mode: "local",
backend: "sqlite",
sqlitePath: join(root, "memory.sqlite"),
scopedApiKeys: {
"scoped-agent-a-key": {
namespace: { source: "agent-a", profileId: "default" },
scopes: ["memory:read"]
}
}
}
} }));
const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer");
const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]);
try {
const endpoint = await waitForWrittenEndpoint(configPath);
const readUrl = `${endpoint}/api/v1/admin/memory-token-budget`;

const anonymous = await fetch(readUrl);
expect(anonymous.status).toBe(401);

const unknownToken = await fetch(readUrl, { headers: { "x-api-key": "not-a-configured-key" } });
expect(unknownToken.status).toBe(401);
} finally {
listen.mock.calls[0]?.[0].onShutdownRequested?.();
await running;
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
}
});

it("keeps the storage token authoritative when scoped api keys are configured too", async () => {
const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-token-"));
const configPath = join(root, "config.yaml");
writeFileSync(configPath, YAML.stringify({ memmyMemory: {
storage: {
mode: "local",
backend: "sqlite",
sqlitePath: join(root, "memory.sqlite"),
token: "local-admin-token",
scopedApiKeys: {
"scoped-agent-a-key": {
namespace: { source: "agent-a", profileId: "default" },
scopes: ["memory:read"]
}
}
}
} }));
const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer");
const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]);
try {
const endpoint = await waitForWrittenEndpoint(configPath);
const readUrl = `${endpoint}/api/v1/admin/memory-token-budget`;

const withToken = await fetch(readUrl, {
headers: { authorization: "Bearer local-admin-token" }
});
expect(withToken.status).toBe(200);

const withScopedKey = await fetch(readUrl, {
headers: { "x-api-key": "scoped-agent-a-key" }
});
expect(withScopedKey.status).toBe(200);

const anonymous = await fetch(readUrl);
expect(anonymous.status).toBe(401);
} finally {
listen.mock.calls[0]?.[0].onShutdownRequested?.();
await running;
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
}
});
});

async function waitForWrittenEndpoint(configPath: string): Promise<string> {
Expand Down
Loading