Merge pull request #280 from Tencent-Hunyuan/fix/multi-user-hardening
fix(memhub): multi-user responses echo user_id, cross-user delete answers 404, Hermes profiles isolated for any root
This commit is contained in:
26 files changed
+626
-60
No files matched your search
@@ -4,6 +4,33 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub multi-user hardening (follow-up to #250):
|
||||
- Read responses name the user they were read for: `user_id` (the one
|
||||
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
|
||||
`/memory-library/layers`, `/user-profile`, `/home-summary`,
|
||||
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
|
||||
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
|
||||
first (`recall`) event. The Host Bridge context validator accepts the new
|
||||
field and still accepts a Core without it.
|
||||
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
|
||||
user's memory and for an id that does not exist (the same response, so
|
||||
existence is still not revealed), and also when the SDK finds nothing to
|
||||
delete; 204 only when the caller's own memory was deleted. The App treats
|
||||
that 404 as already gone (a double-click delete no longer shows an
|
||||
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
|
||||
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
|
||||
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
|
||||
live sync), so a root granted above `~/.hermes` cannot pick up another
|
||||
user's profile. Connecting refuses a root that is the `profiles`
|
||||
container itself (422 `hermes_root_is_profiles_container`) and a root
|
||||
equal to, containing or inside another user's active Hermes grant (409
|
||||
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
|
||||
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
|
||||
allowed. The CLI explains both codes.
|
||||
- `memhub sources connect --user-id` with an invalid id now says the user
|
||||
id need not equal the Hermes profile name (e.g. `--user-id wife-work
|
||||
--root ~/.hermes/profiles/wife:work`).
|
||||
|
||||
- MemHub's DeepSeek Harness app page can grant recall again (#230). The
|
||||
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
|
||||
profile installed from the CLI stayed `install-only` with no way to approve
|
||||
|
||||
@@ -137,6 +137,15 @@ memhub profile
|
||||
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
|
||||
用户长期画像,不通过普通 Search 拼装。
|
||||
|
||||
读接口都回显 `user_id`(实际读的用户,未指定时为 `local`):
|
||||
`GET /api/v2/memory-library`、`/memory-library/layers`、`/user-profile`、
|
||||
`/user-profile/versions`、`/home-summary`、`/memories/graph`、`/raw`,
|
||||
`POST /api/v2/context/search`、`/api/v1/recall`、`/api/v2/memories/ask`;
|
||||
`/memories/ask/stream` 写在第一个 `recall` 事件上。
|
||||
`DELETE /api/v2/memories/{id}` 只在删掉调用者自己的记忆时回 204;另一个用户的
|
||||
记忆和不存在的 ID 一律回 404 `memory_not_found`(不暴露是否存在),调用方按
|
||||
「已不存在」处理。
|
||||
|
||||
返回值:
|
||||
|
||||
```python
|
||||
|
||||
+25
-13
@@ -1938,7 +1938,7 @@ async function runCommand(
|
||||
return reject(
|
||||
descriptor,
|
||||
asJson,
|
||||
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。",
|
||||
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。用户 ID 不必等于 Hermes profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。",
|
||||
);
|
||||
}
|
||||
if (!parsed) return usage();
|
||||
@@ -2328,29 +2328,41 @@ async function runCommand(
|
||||
...userIdParameters(userId),
|
||||
}),
|
||||
);
|
||||
// A delete answered 404 `memory_not_found` is already gone (deleted
|
||||
// before, or never this user's): what was asked for holds, so it exits 0
|
||||
// as committed and says so, instead of failing a retry or a double run.
|
||||
const alreadyGone =
|
||||
parsed.kind === "delete" &&
|
||||
outcome.state === "rejected" &&
|
||||
outcome.code === "memory_not_found";
|
||||
const state = alreadyGone ? "committed" : outcome.state;
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
command: `memories ${parsed.kind}`,
|
||||
state: outcome.state,
|
||||
state,
|
||||
operation: parsed.kind,
|
||||
memory_id: parsed.memoryId,
|
||||
...(outcome.state === "committed"
|
||||
? parsed.kind === "update"
|
||||
? { result: outcome.data }
|
||||
: {}
|
||||
: { code: outcome.code }),
|
||||
...(alreadyGone
|
||||
? { already_gone: true }
|
||||
: outcome.state === "committed"
|
||||
? parsed.kind === "update"
|
||||
? { result: outcome.data }
|
||||
: {}
|
||||
: { code: outcome.code }),
|
||||
};
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: outcome.state === "committed"
|
||||
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
|
||||
: outcome.code === "memory_id_ambiguous"
|
||||
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
|
||||
: `操作失败:${outcome.code}\n`,
|
||||
: alreadyGone
|
||||
? `未找到记忆 ${parsed.memoryId}(可能已删除),无需再删。\n`
|
||||
: outcome.state === "committed"
|
||||
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
|
||||
: outcome.code === "memory_id_ambiguous"
|
||||
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
|
||||
: `操作失败:${outcome.code}\n`,
|
||||
);
|
||||
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
return state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
if (
|
||||
named === "settings" &&
|
||||
|
||||
@@ -258,7 +258,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "sources",
|
||||
summary:
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(任何层级的 profiles/ 目录都会被排除,各用户互不读取)。--root 不能是 profiles 目录本身,也不能与另一个用户的 Hermes 目录相同或互相包含(~/.hermes 与 ~/.hermes/profiles/<name> 除外)。",
|
||||
usage:
|
||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
options: [
|
||||
@@ -270,7 +270,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
|
||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;用户 ID 不必等于 profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。其他来源只能是 local。",
|
||||
},
|
||||
{
|
||||
flag: "--root <path>",
|
||||
@@ -397,7 +397,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "memories",
|
||||
summary:
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes;要删的记忆已不存在(或不属于该用户)时提示未找到,按已删除处理并退出 0。",
|
||||
usage:
|
||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
|
||||
options: [
|
||||
|
||||
@@ -348,12 +348,25 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** What to do about a refusal whose code alone does not say. */
|
||||
const FLOW_CODE_HINTS: Record<string, string> = {
|
||||
hermes_root_is_profiles_container:
|
||||
"--root 指向的是 profiles 目录本身;请指向其中一个 profile,例如 ~/.hermes/profiles/wife。",
|
||||
hermes_root_overlaps_other_user:
|
||||
"--root 与另一个用户已接入的 Hermes 目录相同或互相包含,会读到对方的记录;请换一个目录,或先撤销对方的接入。~/.hermes 与 ~/.hermes/profiles/<name> 可以分属两个用户。",
|
||||
};
|
||||
|
||||
export function renderFlowText(document: FlowDocument): string {
|
||||
const lines = [
|
||||
`操作状态:${document.state}`,
|
||||
`操作 ID:${document.operation_id ?? "无"}`,
|
||||
"code" in document ? `代码:${document.code}` : "已返回有界回执。",
|
||||
];
|
||||
const hint =
|
||||
"code" in document && typeof document.code === "string"
|
||||
? FLOW_CODE_HINTS[document.code]
|
||||
: undefined;
|
||||
if (hint) lines.push(hint);
|
||||
// A partial success or an all-rejected batch must name each source that was
|
||||
// not accepted; a bare "partial" leaves the user with nothing to act on.
|
||||
if (
|
||||
|
||||
@@ -366,6 +366,33 @@ describe("memories update / delete pass the namespace", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a delete answered 404 as already gone and exits 0", async () => {
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 404,
|
||||
data: { detail: "memory_not_found" },
|
||||
});
|
||||
const json = await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"gone-1",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(json.code).toBe(0);
|
||||
expect(JSON.parse(json.stdout)).toEqual({
|
||||
schema_version: 2,
|
||||
product: expect.any(String),
|
||||
command: "memories delete",
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
memory_id: "gone-1",
|
||||
already_gone: true,
|
||||
});
|
||||
const text = await capturedRun(["memories", "delete", "gone-1", "--yes"]);
|
||||
expect(text.code).toBe(0);
|
||||
expect(text.stdout).toContain("未找到记忆 gone-1");
|
||||
});
|
||||
|
||||
it("explains an ambiguous id instead of a bare code", async () => {
|
||||
transport.delete.mockResolvedValue({
|
||||
status: 409,
|
||||
|
||||
@@ -373,6 +373,48 @@ describe("sources connect for a Hermes profile", () => {
|
||||
}
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the user id need not equal the Hermes profile name", async () => {
|
||||
const result = await capturedRun([
|
||||
"sources",
|
||||
"connect",
|
||||
"hermes",
|
||||
"--user-id",
|
||||
"wife:work",
|
||||
"--root",
|
||||
"~/.hermes/profiles/wife:work",
|
||||
]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain("用户 ID 不必等于 Hermes profile 名");
|
||||
expect(result.stderr).toContain(
|
||||
"--user-id wife-work --root ~/.hermes/profiles/wife:work",
|
||||
);
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a refused overlapping or profiles-container root", async () => {
|
||||
for (const code of [
|
||||
"hermes_root_is_profiles_container",
|
||||
"hermes_root_overlaps_other_user",
|
||||
]) {
|
||||
transport.post.mockResolvedValueOnce({
|
||||
status: code.endsWith("container") ? 422 : 409,
|
||||
data: { detail: code },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"sources",
|
||||
"connect",
|
||||
"hermes",
|
||||
"--user-id",
|
||||
"kid",
|
||||
"--root",
|
||||
"/profiles",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain(code);
|
||||
expect(result.stdout).toContain("--root");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
|
||||
@@ -130,6 +130,7 @@ from one_memory.hy_sdk_types import (
|
||||
from one_memory.imports.access import (
|
||||
AuthorizedSourceRoot,
|
||||
SourceAccessError,
|
||||
resolved_source_root,
|
||||
root_identity_matches,
|
||||
source_root_identity,
|
||||
)
|
||||
@@ -169,6 +170,11 @@ from one_memory.imports.locations import SourceLocation, inspect_source_location
|
||||
from one_memory.imports.models import EvidenceRecord
|
||||
from one_memory.imports.runner import live_sync_arrival
|
||||
from one_memory.imports.sources.common import SourceInstallState
|
||||
from one_memory.imports.sources.hermes import (
|
||||
HERMES_ROOT_IS_PROFILES_CONTAINER,
|
||||
hermes_root_conflict,
|
||||
hermes_root_is_profiles_container,
|
||||
)
|
||||
from one_memory.imports.sources.registry import (
|
||||
RootCandidateKind,
|
||||
SourceRegistration,
|
||||
@@ -531,11 +537,25 @@ class SdkMemoryUpdateRequest(BaseModel):
|
||||
class SdkUserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: The user whose profile this is: the `user_id` asked for, else `local`.
|
||||
user_id: str
|
||||
request_id: str
|
||||
profile: dict[str, object] | None
|
||||
elapsed_ms: float = Field(ge=0)
|
||||
|
||||
|
||||
class SdkMemoryLibraryPage(HySdkMemoryPage):
|
||||
"""`GET /api/v2/memory-library`: one SDK page and the user it was read for."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
user_id: str
|
||||
|
||||
@classmethod
|
||||
def of(cls, page: HySdkMemoryPage, user_id: str) -> "SdkMemoryLibraryPage":
|
||||
return cls(**dict(page), user_id=user_id)
|
||||
|
||||
|
||||
class SdkProfileVersionsResponse(HySdkProfileVersions):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -590,6 +610,8 @@ class SdkRawItem(HySdkRawRecord):
|
||||
class SdkRawPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: The user whose raw turns these are.
|
||||
user_id: str
|
||||
items: tuple[SdkRawItem, ...] = ()
|
||||
total: int = Field(ge=0)
|
||||
offset: int = Field(ge=0)
|
||||
@@ -603,6 +625,8 @@ class SdkRawPage(BaseModel):
|
||||
class SdkLayerCounts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: The user whose memories were counted.
|
||||
user_id: str
|
||||
#: Every SDK layer, zero when empty.
|
||||
layers: dict[str, int]
|
||||
total: int = Field(ge=0)
|
||||
@@ -998,6 +1022,8 @@ class AskMemoryRequest(SdkSearchFilters):
|
||||
|
||||
class SdkHomeSummary(BaseModel):
|
||||
schema_version: Literal[2] = 2
|
||||
#: The user the card was read for.
|
||||
user_id: str
|
||||
memory_count: int
|
||||
latest: HySdkMemoryRecord | None
|
||||
observed_at: datetime
|
||||
@@ -1024,6 +1050,8 @@ class MemoryGraphEdge(BaseModel):
|
||||
|
||||
|
||||
class MemoryGraphResponse(BaseModel):
|
||||
#: The user whose memories were mapped.
|
||||
user_id: str
|
||||
total_memories: int
|
||||
projected_memories: int
|
||||
nodes: tuple[MemoryGraphNode, ...]
|
||||
@@ -2698,6 +2726,7 @@ def create_app(
|
||||
)
|
||||
if not root_identity_matches(registration.candidate.path, request.expected_root_id):
|
||||
raise HTTPException(status_code=409, detail="source_identity_changed")
|
||||
await _refuse_overlapping_root(source_id, registration.candidate.path, DEFAULT_USER_ID)
|
||||
root_changed = prior is not None and prior.root_id != request.expected_root_id
|
||||
if root_changed:
|
||||
source_jobs = [job for job in _job_store(app).list_jobs() if job.source_id == source_id]
|
||||
@@ -3098,6 +3127,20 @@ def create_app(
|
||||
registrations = [_require_registration(app, source_id) for source_id in source_ids]
|
||||
if request.request_id is None:
|
||||
raise HTTPException(status_code=409, detail="source_confirmation_required")
|
||||
for registration in registrations:
|
||||
if registration.descriptor.source_id in MULTI_USER_SOURCES:
|
||||
try:
|
||||
resolved = await asyncio.to_thread(
|
||||
app.state.source_candidate_store.resolve, registration
|
||||
)
|
||||
except SourceCandidateError:
|
||||
# The connect below reports the missing candidate itself.
|
||||
continue
|
||||
await _refuse_overlapping_root(
|
||||
registration.descriptor.source_id,
|
||||
resolved.candidate.path,
|
||||
DEFAULT_USER_ID,
|
||||
)
|
||||
history_after = await _onboarding_history_after(
|
||||
request,
|
||||
registrations,
|
||||
@@ -3197,6 +3240,42 @@ def create_app(
|
||||
raise
|
||||
return ConnectSourcesResponse(**result.model_dump())
|
||||
|
||||
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
|
||||
"""Refuse a Hermes root that would read another user's files.
|
||||
|
||||
422 `hermes_root_is_profiles_container` for `.../profiles` itself;
|
||||
409 `hermes_root_overlaps_other_user` for a root equal to, holding or
|
||||
inside another user's active grant, unless one is the other's
|
||||
`profiles/<name>` (the scan never enters `profiles/`).
|
||||
"""
|
||||
if source_id not in MULTI_USER_SOURCES:
|
||||
return
|
||||
if hermes_root_is_profiles_container(root):
|
||||
raise HTTPException(status_code=422, detail=HERMES_ROOT_IS_PROFILES_CONTAINER)
|
||||
|
||||
def conflict_with_others() -> str | None:
|
||||
try:
|
||||
mine = resolved_source_root(root)
|
||||
except SourceAccessError:
|
||||
# Unreadable or missing: the grant that follows refuses it
|
||||
# with its own code.
|
||||
return None
|
||||
others = [
|
||||
Path(path)
|
||||
for receipt, path in _grant_store(app).grants()
|
||||
if receipt.source_id == source_id
|
||||
and receipt.user_id != user_id
|
||||
and receipt.revoked_at is None
|
||||
]
|
||||
return hermes_root_conflict(mine, others)
|
||||
|
||||
conflict = await asyncio.to_thread(conflict_with_others)
|
||||
if conflict is not None:
|
||||
raise HTTPException(
|
||||
status_code=422 if conflict == HERMES_ROOT_IS_PROFILES_CONTAINER else 409,
|
||||
detail=conflict,
|
||||
)
|
||||
|
||||
async def _connect_other_root(request: ConnectSourcesRequest) -> ConnectSourcesResponse:
|
||||
"""Grant one Hermes root to a user: `~/.hermes/profiles/<name>` for its own user.
|
||||
|
||||
@@ -3226,6 +3305,7 @@ def create_app(
|
||||
root = Path(request.root)
|
||||
if not root.is_absolute():
|
||||
raise HTTPException(status_code=422, detail="source_root_must_be_absolute")
|
||||
await _refuse_overlapping_root(source_id, root, request.user_id)
|
||||
# Named outright by the caller, like an environment override:
|
||||
# no saved candidate may replace it, and naming it confirms it.
|
||||
registration = replace(
|
||||
@@ -3761,6 +3841,7 @@ def create_app(
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
total = stats.get("total") if isinstance(stats, dict) else None
|
||||
return SdkHomeSummary(
|
||||
user_id=user_id,
|
||||
memory_count=total if isinstance(total, int) and total >= 0 else page.total,
|
||||
latest=page.memories[0] if page.memories else None,
|
||||
observed_at=datetime.now(UTC),
|
||||
@@ -3768,7 +3849,7 @@ def create_app(
|
||||
|
||||
@app.get(
|
||||
"/api/v2/memory-library",
|
||||
response_model=HySdkMemoryPage,
|
||||
response_model=SdkMemoryLibraryPage,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_memory_library(
|
||||
@@ -3785,7 +3866,7 @@ def create_app(
|
||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||
session_ids: list[str] | None = Query(default=None), # noqa: B008
|
||||
agent_ids: list[str] | None = Query(default=None), # noqa: B008
|
||||
) -> HySdkMemoryPage:
|
||||
) -> SdkMemoryLibraryPage:
|
||||
"""One page of memories across chat, working and coding.
|
||||
|
||||
Without `query`: `list_memories(scene="all")`, newest update first;
|
||||
@@ -3831,7 +3912,7 @@ def create_app(
|
||||
status_code=422,
|
||||
detail="sdk_search_supports_active_status_only",
|
||||
)
|
||||
return await _library_search_page(
|
||||
searched = await _library_search_page(
|
||||
app,
|
||||
client,
|
||||
query=search_query,
|
||||
@@ -3844,6 +3925,7 @@ def create_app(
|
||||
user_id=user_id,
|
||||
filters=filters,
|
||||
)
|
||||
return SdkMemoryLibraryPage.of(searched, user_id)
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id=user_id,
|
||||
@@ -3861,7 +3943,7 @@ def create_app(
|
||||
raw, limit=limit, offset=offset
|
||||
)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
return page
|
||||
return SdkMemoryLibraryPage.of(page, user_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
@@ -3907,6 +3989,7 @@ def create_app(
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
return SdkLayerCounts(
|
||||
user_id=user_id,
|
||||
layers=counts,
|
||||
total=sum(counts.values()),
|
||||
statuses=tuple(chosen) if chosen else None,
|
||||
@@ -4067,6 +4150,7 @@ def create_app(
|
||||
)
|
||||
)
|
||||
return MemoryGraphResponse(
|
||||
user_id=user_id,
|
||||
total_memories=total if isinstance(total, int) else len(memories),
|
||||
projected_memories=len(memories),
|
||||
nodes=tuple(nodes.values()),
|
||||
@@ -4088,7 +4172,7 @@ def create_app(
|
||||
profile = HySdkUserProfile.from_sdk(value)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_profile_read_failed") from None
|
||||
return SdkUserProfileResponse.model_validate(profile.model_dump())
|
||||
return SdkUserProfileResponse(user_id=user_id, **profile.model_dump())
|
||||
|
||||
@app.get(
|
||||
"/api/v2/user-profile/versions",
|
||||
@@ -4212,15 +4296,16 @@ def create_app(
|
||||
try:
|
||||
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||
# `delete` takes no user. Another user's memory is answered
|
||||
# exactly like an id that does not exist: 204, nothing
|
||||
# touched, so the status cannot tell that it exists.
|
||||
# exactly like an id that does not exist: 404
|
||||
# `memory_not_found`, nothing touched, so the status cannot
|
||||
# tell that it exists. Callers read 404 as "already gone".
|
||||
current = await asyncio.to_thread(
|
||||
client.get, memory_id, **_namespace_kwargs(kind, variant)
|
||||
)
|
||||
if current is not None and not owns(
|
||||
if current is None or not owns(
|
||||
HySdkMemoryRecord.from_sdk(current).user_id, user_id
|
||||
):
|
||||
return Response(status_code=204)
|
||||
raise HTTPException(status_code=404, detail="memory_not_found")
|
||||
result = await asyncio.to_thread(
|
||||
client.delete, memory_id, **_namespace_kwargs(kind, variant)
|
||||
)
|
||||
@@ -4229,7 +4314,11 @@ def create_app(
|
||||
error_code = result.get("error_code")
|
||||
if error_code or result.get("partial") is True:
|
||||
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
|
||||
if result.get("success") is not True and result.get("deleted_count") != 0:
|
||||
if result.get("success") is not True:
|
||||
# Read a moment ago, found by nobody now (a second delete won
|
||||
# the race): the same answer as an id that never existed.
|
||||
if result.get("deleted_count") == 0:
|
||||
raise HTTPException(status_code=404, detail="memory_not_found")
|
||||
raise HySdkError("hy_memory_delete_rejected")
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -4302,6 +4391,7 @@ def create_app(
|
||||
and (before is None or at < before)
|
||||
)
|
||||
return SdkRawPage(
|
||||
user_id=user_id,
|
||||
items=tuple(SdkRawItem.of(record) for record in records),
|
||||
total=page.total,
|
||||
offset=offset,
|
||||
@@ -4334,6 +4424,7 @@ def create_app(
|
||||
)
|
||||
except MemoryAnswerError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from None
|
||||
answer = answer.model_copy(update={"user_id": request.user_id})
|
||||
if request.question != "__om_probe__" and access is not None:
|
||||
from one_memory.call_event_recorder import ask_call_event, try_record
|
||||
|
||||
@@ -4375,6 +4466,9 @@ def create_app(
|
||||
):
|
||||
if event.get("type") == "final":
|
||||
final = event
|
||||
elif event.get("type") == "recall":
|
||||
# The first event names whose memories answer.
|
||||
event = {**event, "user_id": request.user_id}
|
||||
yield _sse(_serialize_ask_event(event))
|
||||
except MemoryAnswerError:
|
||||
yield _sse({"type": "error", "code": "memory_answer_failed"})
|
||||
@@ -4453,7 +4547,7 @@ def create_app(
|
||||
)
|
||||
except Exception:
|
||||
app.state.call_event_recording_error = True
|
||||
return receipt
|
||||
return receipt.model_copy(update={"user_id": request.user_id})
|
||||
|
||||
@app.get(
|
||||
"/api/v1/memory-processing",
|
||||
|
||||
@@ -155,6 +155,8 @@ class SdkContextSearchResponse(BaseModel):
|
||||
used_characters: int = Field(ge=0, le=20000)
|
||||
truncated: bool
|
||||
total_ms: float = Field(ge=0)
|
||||
#: Whose memories were searched: the request's `user_id`, else `local`.
|
||||
user_id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ContextLaneResult(BaseModel):
|
||||
|
||||
@@ -261,6 +261,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
used_characters=0,
|
||||
truncated=True,
|
||||
total_ms=(time.monotonic() - started) * 1000,
|
||||
user_id=value.user_id,
|
||||
)
|
||||
await record(final, timed_out=True)
|
||||
return final
|
||||
@@ -306,6 +307,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
used_characters=used,
|
||||
truncated=truncated,
|
||||
total_ms=(time.monotonic() - started) * 1000,
|
||||
user_id=value.user_id,
|
||||
)
|
||||
await record(final, timed_out=False)
|
||||
return final
|
||||
|
||||
@@ -436,6 +436,11 @@ def root_identity_matches(root: Path, expected: str) -> bool:
|
||||
return _root_identity_matches_details(resolved, details, expected)
|
||||
|
||||
|
||||
def resolved_source_root(root: Path) -> Path:
|
||||
"""The real directory a root names (symlinks followed), as a grant records it."""
|
||||
return _root_details(root)[0]
|
||||
|
||||
|
||||
def _check_root_identity(root: Path, expected: str | None) -> None:
|
||||
if expected is not None and not root_identity_matches(root, expected):
|
||||
raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED)
|
||||
|
||||
@@ -27,7 +27,7 @@ def discovered_off_loop(
|
||||
*,
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_top_directories: frozenset[str] = frozenset(),
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||
"""The same walk, for callers that have an event loop to keep free.
|
||||
|
||||
@@ -42,7 +42,7 @@ def discovered_off_loop(
|
||||
grant,
|
||||
suffixes=suffixes,
|
||||
max_targets=max_targets,
|
||||
excluded_top_directories=excluded_top_directories,
|
||||
excluded_directory_names=excluded_directory_names,
|
||||
),
|
||||
scope_key=grant.source_id,
|
||||
)
|
||||
@@ -53,15 +53,17 @@ def discover_source_files(
|
||||
*,
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_top_directories: frozenset[str] = frozenset(),
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||
|
||||
The grant and root identity are checked once before the walk. Each listed
|
||||
file keeps the grant's check, which runs again when that file is opened;
|
||||
the open itself refuses symlinks and any inode other than the one listed.
|
||||
`excluded_top_directories` are directories directly under the root that
|
||||
are never entered, such as another user's Hermes profiles.
|
||||
`excluded_directory_names` are directory names never entered, at any
|
||||
depth under the root, such as the `profiles` container that holds other
|
||||
users' Hermes profiles. Any depth, because the granted root may sit above
|
||||
the Hermes home (`~/.hermes` inside it is then `.hermes/profiles`).
|
||||
"""
|
||||
grant.check_root()
|
||||
inspected = 0
|
||||
@@ -69,12 +71,10 @@ def discover_source_files(
|
||||
grant.root,
|
||||
followlinks=False,
|
||||
):
|
||||
at_root = Path(directory) == grant.root
|
||||
child_directories[:] = sorted(
|
||||
child
|
||||
for child in child_directories
|
||||
if not (Path(directory) / child).is_symlink()
|
||||
and not (at_root and child in excluded_top_directories)
|
||||
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
||||
)
|
||||
for filename in sorted(filenames):
|
||||
if Path(filename).suffix.lower() not in suffixes:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Iterable, Iterator
|
||||
from functools import partial
|
||||
from pathlib import PurePosixPath
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
@@ -88,7 +88,7 @@ class HermesSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".jsonl", ".db"}),
|
||||
max_targets=10_000,
|
||||
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||
):
|
||||
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
|
||||
item.relative_path.as_posix()
|
||||
@@ -102,7 +102,7 @@ class HermesSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".jsonl", ".db"}),
|
||||
max_targets=options.max_scan_targets or 100_000,
|
||||
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||
):
|
||||
yield ScanProgressEvent(
|
||||
phase=ScanPhase.DISCOVER,
|
||||
@@ -253,16 +253,62 @@ def parse_hermes_rollout_record(
|
||||
#: own `state.db` and sessions. A profile is another person's history: it is
|
||||
#: read only through its own grant (its own MemHub user), never as part of the
|
||||
#: root above it. Hermes' own export skips this directory for the same reason.
|
||||
#:
|
||||
#: Skipped at any depth, not only directly under the root: a root granted
|
||||
#: above the Hermes home holds `.hermes/profiles/`. Hermes keeps nothing of
|
||||
#: its own under a directory of that name (its history is `state.db` and
|
||||
#: `sessions/`), so nothing real is lost.
|
||||
HERMES_PROFILES_DIRECTORY = "profiles"
|
||||
|
||||
|
||||
def _is_hermes_target(relative_path: str) -> bool:
|
||||
path = PurePosixPath(relative_path)
|
||||
if path.parts and path.parts[0] == HERMES_PROFILES_DIRECTORY:
|
||||
if HERMES_PROFILES_DIRECTORY in path.parts[:-1]:
|
||||
return False
|
||||
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
|
||||
|
||||
|
||||
def hermes_root_is_profiles_container(root: Path) -> bool:
|
||||
"""`profiles` itself holds everyone's profiles; it is never one person's history."""
|
||||
return root.name == HERMES_PROFILES_DIRECTORY
|
||||
|
||||
|
||||
def hermes_root_conflict(mine: Path, other_users_roots: Iterable[Path]) -> str | None:
|
||||
"""Why the resolved root `mine` may not be granted beside other users' Hermes roots.
|
||||
|
||||
Paths are compared resolved (symlinks followed; the other users' roots are
|
||||
resolved here). Two roots that are equal or nest would read the same files
|
||||
for two users, except when one is exactly the other's `profiles/<name>`:
|
||||
the scan above never enters `profiles/`, so the two stay apart (`local` on
|
||||
`~/.hermes`, a user on `~/.hermes/profiles/<name>`). None when it may.
|
||||
"""
|
||||
if hermes_root_is_profiles_container(mine):
|
||||
return HERMES_ROOT_IS_PROFILES_CONTAINER
|
||||
for other in other_users_roots:
|
||||
theirs = _real(other)
|
||||
if mine == theirs:
|
||||
return HERMES_ROOT_OVERLAPS_OTHER_USER
|
||||
for parent, child in ((mine, theirs), (theirs, mine)):
|
||||
if parent in child.parents:
|
||||
relative = child.relative_to(parent).parts
|
||||
if len(relative) != 2 or relative[0] != HERMES_PROFILES_DIRECTORY:
|
||||
return HERMES_ROOT_OVERLAPS_OTHER_USER
|
||||
return None
|
||||
|
||||
|
||||
#: 422: the root is the `profiles` container, not a profile.
|
||||
HERMES_ROOT_IS_PROFILES_CONTAINER = "hermes_root_is_profiles_container"
|
||||
#: 409: the root is, holds or sits inside another user's Hermes root.
|
||||
HERMES_ROOT_OVERLAPS_OTHER_USER = "hermes_root_overlaps_other_user"
|
||||
|
||||
|
||||
def _real(path: Path) -> Path:
|
||||
try:
|
||||
return path.expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError):
|
||||
return path.expanduser().absolute()
|
||||
|
||||
|
||||
def _read_rollout(
|
||||
source_file: AuthorizedSourceFile,
|
||||
options: ScanOptions,
|
||||
|
||||
@@ -135,6 +135,8 @@ class RecallReceipt(BaseModel):
|
||||
#: exists yet (`fallback_reason`). Raw hits are excerpts, not memories.
|
||||
mode: Literal["memory", "raw"] = "memory"
|
||||
fallback_reason: Literal["model_unavailable", "no_memories"] | None = None
|
||||
#: Whose memories were searched: the request's `user_id`, else `local`.
|
||||
user_id: str = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class RecallTarget(Protocol):
|
||||
|
||||
@@ -33,7 +33,7 @@ from one_memory.model_profiles import (
|
||||
)
|
||||
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
from one_memory.user_scope import owns
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, owns
|
||||
|
||||
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
|
||||
_GROUNDING_LIMIT = 6
|
||||
@@ -62,6 +62,8 @@ class SdkMemoryAnswer(BaseModel):
|
||||
answer: str
|
||||
memories: tuple[SdkAskMemory, ...]
|
||||
mode: Literal["generated", "extractive", "empty"]
|
||||
#: Whose memories answered: the request's `user_id`, else `local`.
|
||||
user_id: str = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class _PromptMemory:
|
||||
|
||||
@@ -293,6 +293,7 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
|
||||
response = await client.get("/api/v2/user-profile")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"user_id": "local",
|
||||
"request_id": "profile-request",
|
||||
"profile": {
|
||||
"memory_id": "l0-head",
|
||||
|
||||
@@ -334,7 +334,9 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
|
||||
|
||||
assert (await client.get(path)).status_code == 404
|
||||
assert (await client.put(path, json={"content": "stolen"})).status_code == 404
|
||||
assert (await client.delete(path)).status_code == 204
|
||||
refused = await client.delete(path)
|
||||
assert refused.status_code == 404
|
||||
assert refused.json()["detail"] == "memory_not_found"
|
||||
assert sdk.memories[wife_memory]["content"] == "wife drinks coffee"
|
||||
|
||||
own = await client.get(path, params={"user_id": "wife"})
|
||||
@@ -343,6 +345,36 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
|
||||
assert updated.status_code == 200
|
||||
assert (await client.delete(path, params={"user_id": "wife"})).status_code == 204
|
||||
assert wife_memory not in sdk.memories
|
||||
# A second delete (a double click) finds nothing: 404, already gone.
|
||||
again = await client.delete(path, params={"user_id": "wife"})
|
||||
assert again.status_code == 404
|
||||
assert again.json()["detail"] == "memory_not_found"
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_a_delete_that_loses_the_race_answers_404_like_a_missing_id(tmp_path, monkeypatch) -> None:
|
||||
"""Read as the caller's own, then gone before the SDK deletes it."""
|
||||
|
||||
async def scenario(app, sdk, client):
|
||||
await _write_both_users(app, sdk, client)
|
||||
[mine] = [
|
||||
memory_id
|
||||
for memory_id, row in sdk.memories.items()
|
||||
if row["content"] == "local drinks tea"
|
||||
]
|
||||
original = sdk.delete
|
||||
|
||||
def raced(memory_id, kind=None, variant=None):
|
||||
sdk.memories.pop(memory_id, None)
|
||||
return original(memory_id, kind=kind, variant=variant)
|
||||
|
||||
sdk.delete = raced # type: ignore[method-assign]
|
||||
response = await client.delete(f"/api/v2/memories/{mine}")
|
||||
missing = await client.delete("/api/v2/memories/no-such-memory")
|
||||
assert _observed(response) == _observed(missing)
|
||||
assert response.status_code == 404
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
@@ -426,6 +458,52 @@ async def _true() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
@pytest.mark.parametrize("user", [None, "wife"])
|
||||
def test_every_read_echoes_the_user_it_was_read_for(tmp_path, monkeypatch, user) -> None:
|
||||
"""Each read names the user it answered for: the one asked for, else `local`."""
|
||||
|
||||
async def scenario(app, sdk, client):
|
||||
await _write_both_users(app, sdk, client)
|
||||
monkeypatch.setattr(
|
||||
"one_memory.api._recall_model_ready", lambda _app: _true(), raising=True
|
||||
)
|
||||
app.state.recall_target = _sdk_recall_target(app, "local")
|
||||
expected = user or "local"
|
||||
params = {} if user is None else {"user_id": user}
|
||||
body = {} if user is None else {"user_id": user}
|
||||
reads = [
|
||||
await client.get("/api/v2/memory-library", params=params),
|
||||
await client.get("/api/v2/memory-library", params={**params, "query": "route"}),
|
||||
await client.get("/api/v2/memory-library/layers", params=params),
|
||||
await client.get("/api/v2/user-profile", params=params),
|
||||
await client.get("/api/v2/home-summary", params=params),
|
||||
await client.get("/api/v2/memories/graph", params=params),
|
||||
await client.get("/api/v2/raw", params=params),
|
||||
await client.post(
|
||||
"/api/v2/context/search", json={"query": "drinks", "kinds": ["memory"], **body}
|
||||
),
|
||||
await client.post("/api/v1/recall", json={"query": "drinks", **body}),
|
||||
await client.post("/api/v2/memories/ask", json={"question": "what drink", **body}),
|
||||
]
|
||||
for response in reads:
|
||||
assert response.status_code == 200, (response.request.url, response.text)
|
||||
assert response.json()["user_id"] == expected, response.request.url
|
||||
|
||||
async with client.stream(
|
||||
"POST", "/api/v2/memories/ask/stream", json={"question": "what drink", **body}
|
||||
) as streamed:
|
||||
events = [
|
||||
json.loads(line[5:])
|
||||
async for line in streamed.aiter_lines()
|
||||
if line.startswith("data:")
|
||||
]
|
||||
assert events[0]["type"] == "recall"
|
||||
assert events[0]["user_id"] == expected
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "kwargs"),
|
||||
@@ -569,6 +647,26 @@ def test_a_hermes_root_scan_never_reads_its_profiles(tmp_path) -> None:
|
||||
assert _scan_texts(wife, tmp_path) == ["I am the wife", "hello", "wife's transcript"]
|
||||
|
||||
|
||||
def test_a_profiles_directory_is_skipped_at_any_depth(tmp_path) -> None:
|
||||
"""A root above the Hermes home still never reads `.hermes/profiles/**`."""
|
||||
home = tmp_path / "home"
|
||||
hermes, _wife = _hermes_homes(home)
|
||||
(hermes / "sessions").mkdir()
|
||||
(hermes / "sessions" / "own.jsonl").write_text(
|
||||
json.dumps({"type": "message", "role": "user", "content": "own transcript"}) + "\n"
|
||||
)
|
||||
nested = hermes / "sessions" / "profiles" / "kid" / "sessions"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "k.jsonl").write_text(
|
||||
json.dumps({"type": "message", "role": "user", "content": "kid's transcript"}) + "\n"
|
||||
)
|
||||
|
||||
above = _scan_texts(home, tmp_path)
|
||||
assert "I am the wife" not in above and "kid's transcript" not in above
|
||||
assert set(above) == {"I am the husband", "hi", "own transcript"}
|
||||
assert "kid's transcript" not in _scan_texts(hermes, tmp_path)
|
||||
|
||||
|
||||
def _jobs(database: Path) -> list[sqlite3.Row]:
|
||||
with sqlite3.connect(database) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
@@ -719,6 +817,87 @@ def test_a_migrated_home_with_the_old_grant_triggers_can_connect_and_revoke(tmp_
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
async def _connect(client, user_id: str, root: Path | None = None, **extra):
|
||||
body: dict[str, object] = {"source_ids": ["hermes"], "request_id": str(uuid4()), **extra}
|
||||
if user_id != "local":
|
||||
body["user_id"] = user_id
|
||||
if root is not None:
|
||||
body["root"] = str(root)
|
||||
return await client.post("/api/v1/sources/connect", json=body)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_a_hermes_root_that_is_the_profiles_container_is_refused(tmp_path) -> None:
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
hermes, _wife = _hermes_homes(home)
|
||||
app = application(tmp_path, home=home)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
for user in ("wife", "local"):
|
||||
refused = await _connect(client, user, hermes / "profiles")
|
||||
assert refused.status_code == 422, refused.text
|
||||
assert refused.json()["detail"] == "hermes_root_is_profiles_container"
|
||||
assert app.state.source_grant_store.grants() == ()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_two_users_on_one_hermes_root_or_nested_roots_are_refused(tmp_path) -> None:
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
hermes, wife = _hermes_homes(home)
|
||||
(wife / "sessions").mkdir()
|
||||
link = home / "hermes-link"
|
||||
link.symlink_to(hermes, target_is_directory=True)
|
||||
app = application(tmp_path, home=home)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
location = (await client.post("/api/v1/sources/hermes/location")).json()
|
||||
local = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
|
||||
assert local.status_code == 200, local.text
|
||||
# Same root, the same root through a symlink, a root inside it
|
||||
# that is not `profiles/<name>`, and a root above it.
|
||||
for root in (hermes, link, wife / "sessions", home):
|
||||
refused = await _connect(client, "kid", root)
|
||||
assert refused.status_code == 409, (root, refused.text)
|
||||
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
|
||||
# The standard layout: `local` on ~/.hermes, wife on its profile.
|
||||
accepted = await _connect(client, "wife", wife)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
# Another user on wife's own root is refused too.
|
||||
twin = await _connect(client, "wife2", wife)
|
||||
assert twin.status_code == 409
|
||||
# Once wife's grant is revoked her root is free again.
|
||||
await client.post("/api/v1/sources/hermes/revoke", params={"user_id": "wife"})
|
||||
assert (await _connect(client, "wife2", wife)).status_code == 200
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_local_cannot_take_another_users_hermes_root(tmp_path) -> None:
|
||||
"""The check runs for `local`'s own connect too, not only for other users."""
|
||||
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
hermes, _wife = _hermes_homes(home)
|
||||
other = home / "elsewhere" / ".hermes"
|
||||
_hermes_state_db(other, [(1, "s", "user", "elsewhere")])
|
||||
app = application(tmp_path, home=home)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
# wife took ~/.hermes itself, which is `local`'s default root.
|
||||
taken = await _connect(client, "wife", hermes)
|
||||
assert taken.status_code == 200, taken.text
|
||||
location = (await client.post("/api/v1/sources/hermes/location")).json()
|
||||
refused = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
|
||||
assert refused.status_code == 409, refused.text
|
||||
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
|
||||
# `local` on a root of its own that does not overlap is fine.
|
||||
assert (await _connect(client, "local", other)).status_code == 200
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_only_hermes_takes_another_user_or_a_custom_root(tmp_path) -> None:
|
||||
async def scenario():
|
||||
@@ -852,7 +1031,7 @@ def test_another_users_id_answers_exactly_like_an_id_that_does_not_exist(
|
||||
other = await client.request(method, theirs, **kwargs)
|
||||
absent = await client.request(method, missing, **kwargs)
|
||||
assert _observed(other) == _observed(absent), (method, theirs)
|
||||
assert other.status_code == (204 if method == "DELETE" else 404)
|
||||
assert other.status_code == 404
|
||||
assert sdk.memories == before
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
@@ -13,9 +13,18 @@ vi.mock("axios", () => ({
|
||||
delete: del,
|
||||
interceptors: { request: { use: vi.fn() } },
|
||||
}),
|
||||
isAxiosError: (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "isAxiosError" in value,
|
||||
},
|
||||
}));
|
||||
|
||||
function httpError(status: number, detail: string) {
|
||||
return Object.assign(new Error(`http_${status}`), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { detail } },
|
||||
});
|
||||
}
|
||||
|
||||
import {
|
||||
deleteSdkMemory,
|
||||
getHomeSummary,
|
||||
@@ -100,6 +109,18 @@ describe("paged memory library client", () => {
|
||||
expect(del).toHaveBeenLastCalledWith("/api/v2/memories/c1", undefined);
|
||||
});
|
||||
|
||||
it("treats a delete answered 404 memory_not_found as already gone", async () => {
|
||||
del.mockRejectedValueOnce(httpError(404, "memory_not_found"));
|
||||
await expect(deleteSdkMemory("gone")).resolves.toBeUndefined();
|
||||
|
||||
const other = httpError(404, "source_not_found");
|
||||
del.mockRejectedValueOnce(other);
|
||||
await expect(deleteSdkMemory("x")).rejects.toBe(other);
|
||||
const failed = httpError(503, "hy_memory_delete_failed");
|
||||
del.mockRejectedValueOnce(failed);
|
||||
await expect(deleteSdkMemory("x")).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it("forwards cancellation for inactive or superseded views", async () => {
|
||||
const controller = new AbortController();
|
||||
get.mockResolvedValueOnce({
|
||||
|
||||
@@ -763,9 +763,25 @@ export async function deleteSdkMemory(
|
||||
...(traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : {}),
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
await client.delete(
|
||||
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
|
||||
Object.keys(config).length ? config : undefined,
|
||||
try {
|
||||
await client.delete(
|
||||
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
|
||||
Object.keys(config).length ? config : undefined,
|
||||
);
|
||||
} catch (reason) {
|
||||
// 404 `memory_not_found`: already gone (a second click, or another
|
||||
// window deleted it first). The memory is not there, which is what the
|
||||
// person asked for, so it is not an error.
|
||||
if (isMemoryNotFound(reason)) return;
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isMemoryNotFound(reason: unknown): boolean {
|
||||
return (
|
||||
axios.isAxiosError(reason) &&
|
||||
reason.response?.status === 404 &&
|
||||
apiErrorCode(reason) === "memory_not_found"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,14 @@ node apps/cli/dist/one-memory.cjs sources list
|
||||
```
|
||||
|
||||
- 只有 `hermes` 能带 `--user-id`(非 `local`)或 `--root`;其他来源带了会退 2,什么都不发。
|
||||
- 用户 ID 不必等于 profile 名:profile 名里有 `:` 等字符时另起一个,例如
|
||||
`--user-id wife-work --root ~/.hermes/profiles/wife:work`。
|
||||
- `--root` 里的 `~` 由 CLI 展开为主目录,相对路径按当前目录解析成绝对路径。
|
||||
- 扫描 `~/.hermes` 时会排除 `profiles/**`,所以 `local` 不会读到 wife 的 `state.db`。
|
||||
- 扫描时任何层级名为 `profiles` 的目录都会被排除,所以 `local` 不会读到 wife 的 `state.db`,
|
||||
即使授权的根在 `~/.hermes` 之上。
|
||||
- `--root` 不能是 `profiles` 目录本身(`hermes_root_is_profiles_container`),也不能与另一个
|
||||
用户已接入的 Hermes 目录相同、包含它或在它之内(`hermes_root_overlaps_other_user`,解析
|
||||
symlink 后比较);`~/.hermes` 给 `local`、`~/.hermes/profiles/<name>` 给另一个用户是允许的。
|
||||
- App 不变:它只读写 `local`,其他来源也都固定是 `local`。
|
||||
|
||||
## 3. 看导入进度
|
||||
@@ -140,7 +146,11 @@ node apps/cli/dist/one-memory.cjs write status <request_id> --user-id wife
|
||||
|
||||
- 用户 ID 只能是 1–64 位字母、数字、`.`、`_`、`-`;其他写法退 2,不连 Runtime。
|
||||
- 不带 `--user-id`(或写 `--user-id local`)时请求里不出现 `user_id`,和以前完全一样。
|
||||
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」;write 的 request id 也只属于提交它的用户。
|
||||
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」(HTTP 404 `memory_not_found`,
|
||||
与不存在的 ID 完全一样);write 的 request id 也只属于提交它的用户。
|
||||
- `memories delete <id> --yes` 遇到「未找到」时按已删除处理:提示「未找到记忆 <id>(可能已删除)」,
|
||||
退出 0;`--json` 为 `state: "committed"` 加 `already_gone: true`。App 里重复点删除也不再报错。
|
||||
- 读接口(`memories list`、`profile`、`recall` 等背后的 HTTP 响应)都带 `user_id`,即实际读的是哪个用户。
|
||||
|
||||
### 画像版本(`profile --versions`)
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ Post-cutover hosts use SDK-native Context v2 at
|
||||
`POST /api/v2/context/search`. A v2 response is never converted into v1 by
|
||||
inventing revisions, Evidence IDs, paths, or adoption state.
|
||||
|
||||
Since MemHub 1.0.6 the v2 response also carries `user_id`, the user whose
|
||||
memories were searched (`local` unless the request named one). The Host
|
||||
Bridge validator accepts it as an optional field, so a response from an older
|
||||
Core without it still validates; any other unknown field still fails closed.
|
||||
|
||||
## Transport
|
||||
|
||||
- The Broker `/context` route (used by host hooks and `recall-cli --context`;
|
||||
|
||||
@@ -20,6 +20,20 @@ cursor。旧文件后续追加也必须能被再次发现,不能只有一个
|
||||
用户授权是读取前置条件。环境变量只用于发现候选根,不能代替授权。每个候选
|
||||
文件必须在打开时再次验证 realpath 归属、symlink、普通文件类型和大小预算。
|
||||
|
||||
多用户(仅 Hermes):一个授权根属于一个 `user_id`。Hermes 的扫描、检测和
|
||||
持续同步共用同一次遍历,**任何层级**名为 `profiles` 的目录都不进入(发现
|
||||
helper 的 `excluded_directory_names`),所以授权根即使在 `~/.hermes` 之上,
|
||||
也不会读到 `.hermes/profiles/<name>`。接入时 Core 拒绝:
|
||||
|
||||
- 根的最后一段是 `profiles`(profiles 容器本身):422
|
||||
`hermes_root_is_profiles_container`;
|
||||
- 根(解析 symlink 后)与另一个用户未撤销的 Hermes 授权根相同、包含它或在
|
||||
它之内:409 `hermes_root_overlaps_other_user`。唯一例外是一方正好是另一方
|
||||
的 `profiles/<name>`(`local` 在 `~/.hermes`、另一个用户在
|
||||
`~/.hermes/profiles/<name>`),因为上面的排除已把两者隔开。
|
||||
|
||||
本机 `local` 的接入(`/sources/connect`、`/sources/{id}/authorize`)也走同一检查。
|
||||
|
||||
## Imported source record
|
||||
|
||||
Adapter 输出的 `ScannedEvidence` 是兼容命名的导入中间表示。原文持久化与 raw
|
||||
|
||||
@@ -118,6 +118,9 @@ export interface SdkContextSearchResponse {
|
||||
used_characters: number;
|
||||
truncated: boolean;
|
||||
total_ms: number;
|
||||
/** Whose memories were searched; `local` unless the request named a user.
|
||||
* Absent from a Core older than 1.0.6. */
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/** Native Host Bridge wire response. Schemas stay distinct and are never adapted. */
|
||||
|
||||
@@ -425,6 +425,8 @@ export interface HomeSummary {
|
||||
|
||||
export interface SdkHomeSummary {
|
||||
schema_version: 2;
|
||||
/** The user the card was read for (`local` unless one was named). */
|
||||
user_id?: string;
|
||||
memory_count: number;
|
||||
latest: SdkMemoryRecord | null;
|
||||
observed_at: string;
|
||||
@@ -605,6 +607,8 @@ export interface SdkMemoryRecord {
|
||||
}
|
||||
|
||||
export interface SdkMemoryPage {
|
||||
/** `GET /api/v2/memory-library`: the user the page was read for. */
|
||||
user_id?: string;
|
||||
memories: SdkMemoryRecord[];
|
||||
total: number;
|
||||
limit: number;
|
||||
@@ -621,6 +625,8 @@ export interface SdkMemoryPage {
|
||||
}
|
||||
|
||||
export interface SdkUserProfileResponse {
|
||||
/** The user whose profile this is (`local` unless one was named). */
|
||||
user_id?: string;
|
||||
request_id: string;
|
||||
profile: Record<string, unknown> | null;
|
||||
elapsed_ms: number;
|
||||
@@ -649,6 +655,8 @@ export interface SdkRawRecord {
|
||||
}
|
||||
|
||||
export interface SdkRawPage {
|
||||
/** The user whose raw turns these are. */
|
||||
user_id?: string;
|
||||
items: SdkRawRecord[];
|
||||
total: number;
|
||||
limit: number;
|
||||
@@ -659,6 +667,8 @@ export interface SdkRawPage {
|
||||
|
||||
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
|
||||
export interface SdkLayerCounts {
|
||||
/** The user whose memories were counted. */
|
||||
user_id?: string;
|
||||
layers: Record<string, number>;
|
||||
total: number;
|
||||
statuses: string[] | null;
|
||||
@@ -763,7 +773,8 @@ export type AskMemoryEvent =
|
||||
| { type: "error"; code: string };
|
||||
|
||||
export type SdkAskMemoryEvent =
|
||||
| { type: "recall"; memories: SdkAskMemory[] }
|
||||
/** `user_id`: whose memories answer (Core 1.0.6+). */
|
||||
| { type: "recall"; memories: SdkAskMemory[]; user_id?: string }
|
||||
| { type: "delta"; text: string }
|
||||
| {
|
||||
type: "final";
|
||||
@@ -833,6 +844,8 @@ export interface RecallReceipt {
|
||||
fallback_reason?: "model_unavailable" | "no_memories" | null;
|
||||
/** Sources the caller was not allowed to read, left out of this result. */
|
||||
excluded_sources?: string[];
|
||||
/** Whose memories were searched (`local` unless the request named a user). */
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export interface LiveSyncHealth {
|
||||
@@ -874,6 +887,8 @@ export interface MemoryGraphEdge {
|
||||
}
|
||||
|
||||
export interface MemoryGraphResponse {
|
||||
/** The user whose memories were mapped. */
|
||||
user_id?: string;
|
||||
total_memories: number;
|
||||
projected_memories: number;
|
||||
nodes: MemoryGraphNode[];
|
||||
|
||||
@@ -90,6 +90,11 @@ it("accepts only the SDK context shape", () => {
|
||||
).toBe(false);
|
||||
expect(contextResponseInvalidField(mixed)).toBeNull();
|
||||
expect(contextResponseInvalidField(legacyShape)).toBe("response");
|
||||
// Core 1.0.6 echoes whose memories were searched; an older Core does not.
|
||||
expect(isSdkContextSearchResponse({ ...mixed, user_id: "local" })).toBe(true);
|
||||
expect(isSdkContextSearchResponse({ ...mixed, user_id: "" })).toBe(false);
|
||||
expect(isSdkContextSearchResponse({ ...mixed, user_id: 7 })).toBe(false);
|
||||
expect(isSdkContextSearchResponse({ ...mixed, owner: "local" })).toBe(false);
|
||||
});
|
||||
|
||||
async function fixture(
|
||||
|
||||
@@ -140,15 +140,22 @@ export function isSdkContextSearchResponse(
|
||||
): value is SdkContextSearchResponse {
|
||||
if (
|
||||
!record(value) ||
|
||||
!exact(value, [
|
||||
"schema_version",
|
||||
"request_id",
|
||||
"state",
|
||||
"items",
|
||||
"used_characters",
|
||||
"truncated",
|
||||
"total_ms",
|
||||
]) ||
|
||||
// `user_id` (whose memories were searched) is optional so a Core from
|
||||
// before it was echoed still validates.
|
||||
!exact(
|
||||
value,
|
||||
[
|
||||
"schema_version",
|
||||
"request_id",
|
||||
"state",
|
||||
"items",
|
||||
"used_characters",
|
||||
"truncated",
|
||||
"total_ms",
|
||||
],
|
||||
["user_id"],
|
||||
) ||
|
||||
(value.user_id !== undefined && !text(value.user_id, 1, 64)) ||
|
||||
value.schema_version !== 2 ||
|
||||
!text(value.request_id, 1, 64) ||
|
||||
!member(value.state, ["grounded", "empty", "partial", "unavailable"]) ||
|
||||
@@ -223,8 +230,15 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
function only(value: Record<string, unknown>, keys: string[]): boolean {
|
||||
return Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
function exact(value: Record<string, unknown>, keys: string[]): boolean {
|
||||
return only(value, keys) && keys.every((key) => Object.hasOwn(value, key));
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: string[],
|
||||
optional: string[] = [],
|
||||
): boolean {
|
||||
return (
|
||||
only(value, [...keys, ...optional]) &&
|
||||
keys.every((key) => Object.hasOwn(value, key))
|
||||
);
|
||||
}
|
||||
function text(value: unknown, min: number, max: number): value is string {
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user