diff --git a/crates/ov_cli/src/client.rs b/crates/ov_cli/src/client.rs index 3da6e3777..178409fc9 100644 --- a/crates/ov_cli/src/client.rs +++ b/crates/ov_cli/src/client.rs @@ -45,6 +45,14 @@ impl HttpClient { } } + pub fn user_id(&self) -> Option<&str> { + self.user.as_deref() + } + + pub fn agent_id(&self) -> Option<&str> { + self.agent_id.as_deref() + } + /// Zip a directory to a temporary file fn zip_directory(&self, dir_path: &Path) -> Result { if !dir_path.is_dir() { diff --git a/docs/design/account-namespace-shared-session-design.md b/docs/design/account-namespace-shared-session-design.md new file mode 100644 index 000000000..d69ead6ff --- /dev/null +++ b/docs/design/account-namespace-shared-session-design.md @@ -0,0 +1,986 @@ +# Account 级 User / Agent 命名空间策略与共享 Session 设计方案 + +## Context + +当前 OpenViking 已具备 `account_id / user_id / agent_id` 三元身份模型,但 `user` 与 `agent` 的关系语义仍然不够明确,`session`、目录和检索对这层关系的表达也不一致,主要问题有: + +- 系统内缺少 account 级显式策略来定义 `user scope` 是否再按 `agent` 切分、`agent scope` 是否再按 `user` 切分 +- 命名空间决策分散在多处,目录拓扑无法直接体现 `user` / `agent` 关系 +- `session` 仍然偏向单 user 视角,不能准确表达一个 account 内多 user / 多 agent 协同场景中的真实参与者身份 +- 检索、目录遍历、向量索引、消息身份模型之间缺少一套统一的 owner / participant 语义 + +本次方案的目标,是把 `user` / `agent` 关系显式提升为 account 级命名空间策略,并为后续混合参与者 session 的提取、审计和协同能力打基础。 + +--- + +## 决策摘要 + +- account 级新增两项配置: + - `isolate_user_scope_by_agent: bool` + - `isolate_agent_scope_by_user: bool` +- 默认值: + - `isolate_user_scope_by_agent = false` + - `isolate_agent_scope_by_user = false` +- `user` 与 `agent` 目录都采用显式嵌套 canonical URI,不再依赖 hash space。 +- `session` 升级为 account 级共享作用域,统一落在: + - `viking://session/{session_id}` +- `session add-message` 新增 `role_id`: + - `role=user` 时绑定真实 `user_id` + - `role=assistant` 时绑定真实 `agent_id` +- 检索与目录可见性统一基于以下信息: + - `account_id` + - `uri` + - `owner_user_id` + - `owner_agent_id` +- 本方案按不兼容改造设计: + - 不做旧命名空间兼容 + - 不做历史数据迁移 + - 不做 mixed session `commit / extract` 分流 + +--- + +## 一、目标与非目标 + +### 1.1 目标 + +- 引入 account 级配置,显式定义 `user` 和 `agent` 的共享边界 +- 统一 `user / agent / session` 的逻辑 URI 与底层目录拓扑 +- 将 `session` 升级为 account 级共享会话 +- 在 `session add-message` 中显式记录消息归属身份 +- 统一文件系统可见性与检索过滤规则,避免“能搜到但不能读” + +### 1.2 非目标 + +- 本次不实现历史数据迁移 +- 本次不实现双读双写兼容 +- 本次不定义混合参与者 session 的 `commit / extract` 分流算法 +- 本次不引入 participant 级 ACL,只提供 account 级共享 session + +--- + +## 二、核心设计决策 + +### 2.1 account 级配置字段 + +推荐字段名: + +- `isolate_user_scope_by_agent: bool` +- `isolate_agent_scope_by_user: bool` + +语义如下: + +- `isolate_user_scope_by_agent = false` + - `user` 作用域只按 `user_id` 分区 +- `isolate_user_scope_by_agent = true` + - `user` 作用域按 `(user_id, agent_id)` 分区 +- `isolate_agent_scope_by_user = false` + - `agent` 作用域只按 `agent_id` 分区 +- `isolate_agent_scope_by_user = true` + - `agent` 作用域按 `(agent_id, user_id)` 分区 + +默认值: + +- `isolate_user_scope_by_agent = false` +- `isolate_agent_scope_by_user = false` + +这个默认值的含义是: + +- `user` 记忆按 user 隔离,不区分 agent +- `agent` 记忆按 agent 隔离,不区分 user + +采用这个默认值的原因是: + +- 大多数产品里,用户画像、偏好、个人事实更自然地跟 `user` 绑定,应该能跨 agent 复用 +- 很多 agent 在系统内更像“共享能力单元”而不是“每个 user 一份的私有副本”,默认跨 user 共享更符合直觉 +- 把两者都默认设为“不额外切分”,可以让目录语义最直接,减少初期理解和使用成本 + +### 2.2 配置方式与生命周期 + +这两个字段在 account 创建时配置,并跟随 account 生命周期持久化。 + +持久化位置: + +- `/local/{account_id}/_system/setting.json` + +示例: + +```json +{ + "namespace": { + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": false + } +} +``` + +配置入口: + +- `POST /api/v1/admin/accounts` + +请求示例: + +```json +{ + "account_id": "acme", + "admin_user_id": "alice", + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": false +} +``` + +如果请求里省略这两个字段,则服务端按默认值补齐: + +```json +{ + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": false +} +``` + +读取入口: + +- `GET /api/v1/admin/accounts` +- `GET /api/v1/admin/accounts/{account_id}`(如果后续补充单 account 查询接口) + +持久化要求: + +- account 创建时,服务端将这两个字段写入 `/{account_id}/_system/setting.json` 的 `namespace` 节点 +- 服务启动后加载 account 配置时,这两个字段进入 `AccountNamespacePolicy` +- 后续所有 URI 解析、目录初始化、检索过滤、session 可见性都只能读取这一个来源 + +修改策略: + +- 本阶段不提供 account policy 更新接口 +- account 创建完成后,这两个字段视为不可变 +- 不支持通过手工修改 `setting.json` 的方式在线切换 policy + +原因: + +- 修改任一字段都会改变 canonical URI 结构 +- 已有目录路径、索引字段、session 派生引用都会跟着变化 +- 在没有迁移工具、校验工具和回滚策略之前,不应支持在线修改 + +如果后续业务确实需要调整: + +- 方案一:新建 account,按新 policy 初始化,再做数据迁移 +- 方案二:单独立项做离线迁移,不通过常规 admin API 在线修改 + +### 2.3 配置生效层级 + +这两个字段只在 `account` 上定义,不在 `user`、`agent`、`session` 或请求级覆盖。 + +原因: + +- 命名空间拓扑属于 account 级存储契约,不适合在请求级动态切换 +- 一旦允许运行时切换,就会导致同一 account 内路径布局和检索过滤规则不稳定 +- 统一到 account 级后,目录、索引、权限判断才能保持一致 + +--- + +## 三、命名空间模型 + +### 3.1 总体原则 + +- 逻辑 URI 采用显式嵌套路径,不再依赖不可读的 hash space +- `user`、`agent`、`session` 都有 canonical URI +- 简写 URI 可以保留,但内部必须先 canonicalize,再进入存储、检索和权限判断 + +### 3.2 四种拓扑矩阵 + +#### `isolate_user_scope_by_agent=false`,`isolate_agent_scope_by_user=false` + +```text +viking://user/{user_id}/... +viking://agent/{agent_id}/... +viking://session/{session_id}/... +``` + +底层目录: + +```text +/local/{account_id}/user/{user_id}/... +/local/{account_id}/agent/{agent_id}/... +/local/{account_id}/session/{session_id}/... +``` + +访问规则: + +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,可以访问 `viking://user/ua/...` 下的全部 user 数据,不受当前 agent 影响 +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,可以访问 `viking://agent/aa/...` 下的全部 agent 数据,不受当前 user 影响 +- 不能访问其他 user 的 `viking://user/{other_user_id}/...` +- 不能访问其他 agent 的 `viking://agent/{other_agent_id}/...` +- `session` 按 account 共享,访问规则独立于这两个字段 + +适用场景: + +- 一个 account 内多个 user 共同使用一组标准化 agent,且 agent 的技能、案例、工作区都希望在团队内共享 +- 用户侧资料也希望跨 agent 复用,例如统一的用户画像、偏好、长期事实 +- 更适合协作型工作区,而不是强隔离场景 + +#### `isolate_user_scope_by_agent=false`,`isolate_agent_scope_by_user=true` + +```text +viking://user/{user_id}/... +viking://agent/{agent_id}/user/{user_id}/... +viking://session/{session_id}/... +``` + +底层目录: + +```text +/local/{account_id}/user/{user_id}/... +/local/{account_id}/agent/{agent_id}/user/{user_id}/... +/local/{account_id}/session/{session_id}/... +``` + +访问规则: + +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,可以访问 `viking://user/ua/...` 下的全部 user 数据,不受当前 agent 影响 +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,只能访问 `viking://agent/aa/user/ua/...` +- 不能访问 `viking://agent/aa/user/{other_user_id}/...` +- 不能访问 `viking://agent/{other_agent_id}/user/ua/...` +- `session` 按 account 共享,访问规则独立于这两个字段 + +适用场景: + +- 同一个 user 会在多个 agent 之间切换,希望个人资料和长期偏好能直接复用 +- 不同 user 虽然可能使用同名 agent,但 agent 的案例、instructions、技能配置等沉淀需要彼此隔离 +- 适合希望保留 user 侧共享,但对 agent 侧共享更谨慎的部署方式 + +#### `isolate_user_scope_by_agent=true`,`isolate_agent_scope_by_user=false` + +```text +viking://user/{user_id}/agent/{agent_id}/... +viking://agent/{agent_id}/... +viking://session/{session_id}/... +``` + +底层目录: + +```text +/local/{account_id}/user/{user_id}/agent/{agent_id}/... +/local/{account_id}/agent/{agent_id}/... +/local/{account_id}/session/{session_id}/... +``` + +访问规则: + +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,只能访问 `viking://user/ua/agent/aa/...` +- 不能访问 `viking://user/ua/agent/{other_agent_id}/...` +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,可以访问 `viking://agent/aa/...` 下的全部 agent 数据,不受当前 user 影响 +- 不能访问其他 agent 的 `viking://agent/{other_agent_id}/...` +- `session` 按 account 共享,访问规则独立于这两个字段 + +适用场景: + +- agent 本身代表某种共享岗位能力或团队能力,希望其案例、技能、instructions 等沉淀在所有 user 之间复用 +- 但同一个 user 在不同 agent 下的个人资料或用户记忆不希望互通 +- 适合“agent 是主实体,user 只是调用者”的平台型场景 + +#### `isolate_user_scope_by_agent=true`,`isolate_agent_scope_by_user=true` + +```text +viking://user/{user_id}/agent/{agent_id}/... +viking://agent/{agent_id}/user/{user_id}/... +viking://session/{session_id}/... +``` + +底层目录: + +```text +/local/{account_id}/user/{user_id}/agent/{agent_id}/... +/local/{account_id}/agent/{agent_id}/user/{user_id}/... +/local/{account_id}/session/{session_id}/... +``` + +访问规则: + +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,只能访问 `viking://user/ua/agent/aa/...` +- 不能访问 `viking://user/ua/agent/{other_agent_id}/...` +- 当前请求身份为 `(user_id=ua, agent_id=aa)` 时,只能访问 `viking://agent/aa/user/ua/...` +- 不能访问 `viking://agent/aa/user/{other_user_id}/...` +- 不能访问 `viking://agent/{other_agent_id}/user/ua/...` +- `session` 按 account 共享,访问规则独立于这两个字段 + +适用场景: + +- 每个 `(user, agent)` 组合都应视为独立工作单元,不能共享任何用户记忆或 agent 沉淀 +- 强合规、强审计、强租户内隔离场景 +- 适合外包、敏感业务、或需要最小共享面的部署方式 + +### 3.3 scope 内部结构 + +#### user scope + +```text +memories/ + preferences/ + entities/ + events/ +profile.md +``` + +#### agent scope + +```text +memories/ + cases/ + patterns/ +skills/ +instructions/ +``` + +#### session scope + +```text +messages.jsonl +history/ +tools/ +.meta.json +``` + +### 3.4 简写 URI 规则 + +保留如下简写: + +- `viking://user/...` +- `viking://agent/...` + +但内部一律按当前 account policy 和请求身份展开为 canonical URI。 + +示例:当前身份为 `(account=acme, user=ua, agent=aa)` + +当 `isolate_user_scope_by_agent=false` 时: + +```text +viking://user/memories/preferences/ +=> viking://user/ua/memories/preferences/ +``` + +当 `isolate_user_scope_by_agent=true` 时: + +```text +viking://user/memories/preferences/ +=> viking://user/ua/agent/aa/memories/preferences/ +``` + +当 `isolate_agent_scope_by_user=false` 时: + +```text +viking://agent/memories/cases/ +=> viking://agent/aa/memories/cases/ +``` + +当 `isolate_agent_scope_by_user=true` 时: + +```text +viking://agent/memories/cases/ +=> viking://agent/aa/user/ua/memories/cases/ +``` + +要求: + +- 存储层只处理 canonical URI +- 向量索引写入只处理 canonical URI +- 权限判断只处理 canonical URI + +--- + +## 四、Session 模型重构 + +### 4.1 Session 作用域调整 + +将 `session` 从 user 目录下移出,统一为 account 级共享: + +```text +viking://session/{session_id} +``` + +底层目录: + +```text +/local/{account_id}/session/{session_id} +``` + +设计原因: + +- 同一 account 内可能有多个 user / agent 共同参与一个会话 +- 把 session 绑定在某个 user 根目录下,会让会话共享变得别扭 +- session 作为临时协作容器,更适合按 account 共享,再通过消息身份描述参与者 + +### 4.2 Session 可见性与权限 + +推荐权限: + +- `create / list / get / add-message / commit` + - 同 account 内可访问 +- `delete` + - 仅 `ADMIN` + - 或 `ROOT` + +这意味着: + +- session 可被 account 内其他 user 看到和继续使用 +- 删除权限单独收紧,避免 shared session 的“所有者”语义不清 + +### 4.3 Session 元数据 + +`session/.meta.json` 新增字段: + +- `created_by_user_id` +- `participant_user_ids` +- `participant_agent_ids` + +说明: + +- `created_by_user_id` 表示“这个 session 最初是由哪个请求身份创建出来的” +- 写入时机: + - 第一次真正创建 session 时写入 `.meta.json` + - 具体包括 `POST /api/v1/sessions` 创建 + - 以及未来如果保留 `auto_create` 语义,则第一次自动创建时也写入 +- 写入来源: + - 直接取创建请求对应的 `RequestContext.user.user_id` + - 也就是发起这次创建操作的请求用户 +- 一旦写入,不再随着后续 `add-message` 的 `role_id` 变化而变化 +- 它是审计字段,不直接参与删除权限判断 +- `participant_*` 只用于索引、展示、后续提取分流,不承担 ACL + +也就是说: + +- `created_by_user_id` 不是客户端额外传的参数 +- 它是服务端在“创建 session 的那一刻”从当前登录身份里自动记下来的 +- 它和消息里的 `role_id` 不是一回事 + - `created_by_user_id` 解决“这个 session 最初由谁创建” + - `role_id` 解决“这条消息是谁说的” + +--- + +### 4.4 `add-message` 接口扩展 + +在 `POST /api/v1/sessions/{session_id}/messages` 中新增 `role_id`。 + +示例: + +```json +{ + "role": "user", + "role_id": "ua", + "content": "你好" +} +``` + +```json +{ + "role": "assistant", + "role_id": "aa", + "parts": [...] +} +``` + +规则: + +- `role == "user"` 时,`role_id` 表示真实 `user_id` +- `role == "assistant"` 时,`role_id` 表示真实 `agent_id` +- 其他 role 暂不使用 `role_id` + +### 4.5 请求头语义与 `RequestContext` 解析 + +服务端需要区分“真实调用者身份”和“本次请求按谁的视角执行”。 + +在不同认证模式下,`X-OpenViking-Account`、`X-OpenViking-User`、`X-OpenViking-Agent` 的语义如下: + +- `trusted` 模式 + - `X-OpenViking-Account / User / Agent` 表示真实调用者身份 + - 此时不存在模拟其他用户身份的语义 + +- `api_key + ROOT` + - `X-OpenViking-Account / User / Agent` 表示本次请求的生效上下文 + - `ROOT` 可以显式指定本次请求作用到哪个 account / user / agent + +- `api_key + ADMIN` + - `X-OpenViking-User / Agent` 表示本次请求的生效 user / agent 上下文 + - `ADMIN` 只能在自己的 account 内使用这组请求头模拟用户视角 + - `ADMIN` 不允许通过 `X-OpenViking-Account` 切换到其他 account + +- `api_key + USER` + - `user_id` 只能从当前 user key 对应的身份解析 + - `X-OpenViking-Agent` 仍可用于指定当前请求的 agent 上下文 + - `USER` 不允许通过 `X-OpenViking-User` 切换到其他 user + +`RequestContext` 的语义统一为: + +- `ctx.user` 表示本次请求的生效身份 +- `ctx.role` 表示真实调用者角色 +- namespace 解析、检索过滤、文件系统可见性都按 `ctx.user` 执行 +- 管理权限按 `ctx.role` 判断 + +### 4.6 `add-message` 校验与默认填充 + +`role_id` 的语义如下: + +- `role = "user"` 时,`role_id` 表示消息所属的 `user_id` +- `role = "assistant"` 时,`role_id` 表示消息所属的 `agent_id` + +校验与填充规则: + +- `trusted`、`ROOT`、`ADMIN` + - 可以显式传入 `role_id` + - 如果未显式传入: + - `role = "user"` 时,默认填充 `ctx.user.user_id` + - `role = "assistant"` 时,默认填充 `ctx.user.agent_id` + +- `USER` + - 不接受显式传入的 `role_id` + - `role_id` 只能从 `ctx` 推导 + - 具体规则为: + - `role = "user"` 时,写入 `ctx.user.user_id` + - `role = "assistant"` 时,写入 `ctx.user.agent_id` + +额外约束: + +- `role = "user"` 时,最终写入的 `role_id` 必须是当前 account 下合法的 `user_id` +- `role = "assistant"` 时,最终写入的 `role_id` 作为 `agent_id` 使用 +- 本阶段不引入独立的 agent registry;因此对 `assistant.role_id` 只做格式和上下文一致性校验 + +### 4.7 消息持久化结构 + +`Message` 增加字段: + +```json +{ + "id": "msg_xxx", + "role": "assistant", + "role_id": "aa", + "parts": [...], + "created_at": "2026-04-09T10:00:00Z" +} +``` + +`role_id` 表示这条消息的 actor 身份,不等于真实调用者身份。后续从消息派生 tool / skill / memory 归属时,应以消息自身的 `role + role_id` 为准。 + +### 4.8 Session 派生 URI + +`tool_uri` 统一调整为: + +```text +viking://session/{session_id}/tools/{tool_id} +``` + +后续凡是从 message 中派生 tool / skill / memory 归属时,都应优先使用消息上的 `role + role_id`,而不是默认使用当前请求上下文里的 agent / user。 + + +--- + +## 五、检索、文件系统与可见性 + +### 5.1 问题 + +当前很多逻辑依赖单一字符串字段 `owner_space`。这个字段在以下场景下不够稳定: + +- 命名空间拓扑有四种组合 +- session 改成 account 共享后,不能再把 session 误归到 user owner +- user/agent 简写 URI 需要展开 +- 仅靠一个字符串,不方便做结构化可见性判断 + +### 5.2 索引中的归属信息 + +向量索引和 `Context` 的归属字段调整为: + +- 保留已有字段: + - `account_id` + - `uri` +- 新增字段: + - `owner_user_id` + - `owner_agent_id` +- 移除字段: + - `owner_scope` + +其中: + +- `uri` 使用标准路径 +- `scope` 从 `uri` 的前缀解析得到,不单独存 `owner_scope` +- `owner_user_id` / `owner_agent_id` 用来表达这条数据绑定到哪个 user / agent +- `uri` 和 `owner_user_id` / `owner_agent_id` 需要保持一致,不能互相冲突 + +### 5.3 写入规则 + +所有写入到索引的数据,都必须先确定标准 URI,再由标准 URI 生成 `owner_user_id` / `owner_agent_id`。 + +写入时规则如下: + +#### resource + +```text +uri = viking://resources/... +owner_user_id = null +owner_agent_id = null +``` + +#### user scope + +```text +uri = viking://user/{user_id}/... if isolate_user_scope_by_agent = false +uri = viking://user/{user_id}/agent/{agent_id}/... if isolate_user_scope_by_agent = true + +owner_user_id = user_id +owner_agent_id = null if isolate_user_scope_by_agent = false +owner_agent_id = agent_id if isolate_user_scope_by_agent = true +``` + +#### agent scope + +```text +uri = viking://agent/{agent_id}/... if isolate_agent_scope_by_user = false +uri = viking://agent/{agent_id}/user/{user_id}/... if isolate_agent_scope_by_user = true + +owner_agent_id = agent_id +owner_user_id = null if isolate_agent_scope_by_user = false +owner_user_id = user_id if isolate_agent_scope_by_user = true +``` + +#### session + +```text +uri = viking://session/{session_id}/... +owner_user_id = null +owner_agent_id = null +``` + +### 5.4 查询过滤规则 + +检索时先加: + +```text +account_id == ctx.account_id +``` + +然后根据当前 `(user_id, agent_id)` 和 account policy,算出本次请求可见的路径根: + +#### resource 根路径 + +```text +viking://resources/ +``` + +#### session 根路径 + +```text +viking://session/ +``` + +#### user 根路径 + +```text +viking://user/{user_id}/... if isolate_user_scope_by_agent = false +viking://user/{user_id}/agent/{agent_id}/... if isolate_user_scope_by_agent = true +``` + +#### agent 根路径 + +```text +viking://agent/{agent_id}/... if isolate_agent_scope_by_user = false +viking://agent/{agent_id}/user/{user_id}/... if isolate_agent_scope_by_user = true +``` + +检索过滤由两部分组成: + +- `account_id == ctx.account_id` +- `uri` 必须落在上述可见根路径下 +- `owner_user_id` / `owner_agent_id` 必须满足当前 policy 对应的可见范围 + +如果请求显式传了 `target_uri`,则: + +- 先把 `target_uri` 归一化成标准路径 +- 校验该 `target_uri` 是否在当前请求可见范围内 +- 再把检索范围收敛到该 `target_uri` 前缀下 + +说明: + +- session 在本方案中按 account 共享,因此统一落在 `viking://session/` +- `uri` 用来表达真实路径范围 +- `owner_user_id` / `owner_agent_id` 用来表达绑定到哪个 user / agent +- 文件系统可见性与检索过滤必须复用同一套判断规则 + +### 5.5 结果校验与错误处理 + +检索返回后,服务端仍需对命中结果做一次基于 `uri + owner_user_id + owner_agent_id` 的一致性校验,避免索引脏数据或历史遗留数据泄漏。 + +错误处理如下: + +- `target_uri` 路径形状和当前 policy 不匹配:返回 `400` +- `target_uri` 本身形状没问题,但当前身份无权访问:返回 `403` +- 不传 `target_uri` 时,按当前身份可见范围做全局检索 + +--- + +### 5.6 文件系统与目录初始化 + +#### VikingFS + +需要引入统一的 namespace resolver,承担以下职责: + +- 根据 account policy + 当前身份生成 canonical user / agent 根路径 +- 将简写 URI canonicalize +- 解析 URI 对应的 owner 结构 +- 判断路径是否对当前 `(account_id, user_id, agent_id)` 可见 + +`VikingFS` 的以下能力都需要切到新规则: + +- `_uri_to_path` +- `_path_to_uri` +- `_is_accessible` +- `ls / tree / stat / read / write` + +#### DirectoryInitializer + +目录初始化要区分两类节点: + +- 真实作用域根 + - 需要生成 `memories / skills / instructions` 等预置结构 +- 中间容器目录 + - 只承担路径承载作用 + - 不重复写入预置抽象与 overview + +例如在 `isolate_user_scope_by_agent=true` 且 `isolate_agent_scope_by_user=true` 下: + +```text +viking://agent/{agent_id} +``` + +只是容器; + +```text +viking://agent/{agent_id}/user/{user_id} +``` + +才是实际的 agent scope 根。 + +--- + +## 六、接口与类型变更 + +### 6.1 Admin API + +`POST /api/v1/admin/accounts` 新增: + +- `isolate_user_scope_by_agent` +- `isolate_agent_scope_by_user` + +`GET /api/v1/admin/accounts` 返回同样两项配置。 + +account 级 namespace policy 持久化在: + +- `/local/{account_id}/_system/setting.json` + +文件示例: + +```json +{ + "namespace": { + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": false + } +} +``` + +如果创建请求未显式传值,则服务端使用默认值: + +- `isolate_user_scope_by_agent = false` +- `isolate_agent_scope_by_user = false` + +本阶段不提供修改 account policy 的更新接口。 + +原因: + +- 修改 policy 本质上是重排目录和索引 +- 没有迁移机制前,不应允许运行时修改 +- 直接手工修改 `setting.json` 也不在支持范围内 + +### 6.2 核心类型 + +需要扩展: + +- `AccountInfo` +- `ResolvedIdentity` +- `RequestContext` +- `Message` +- `SessionMeta` +- `Context` + +## 七、影响模块 + +本次方案预计影响如下模块族: + +- 多租户与认证 + - account 配置读写 + - auth middleware + - request context +- 命名空间解析 + - `UserIdentifier` + - 新增统一 resolver / policy +- 文件系统 + - `VikingFS` + - `DirectoryInitializer` +- session + - `Session` + - `SessionService` + - `sessions router` + - `Message` +- 检索与向量 + - `Context` + - embedding message converter + - semantic processor + - collection schema + - vector backend filter +- 接入层 + - Python SDK + - HTTP client + - Rust CLI + +--- + +## 八、兼容性策略 + +本次不采用“全量兼容”或“双读双写”策略,而是按 scope 分别处理: + +### 8.1 user scope + +- `user` 侧默认路径本身就是 `viking://user/{user_id}/...` +- 如果新拓扑仍然采用 user 共享形态,则 AGFS 路径天然兼容 +- 因此本阶段不对 `user` 侧做目录迁移 + +### 8.2 session scope + +- `session` 只在 AGFS 中维护,不进入 VectorDB +- 在确认 account 内不存在同名 `session_id` 的前提下,session 迁移按纯目录移动处理: + +```text +viking://session/{user_id}/{session_id} +-> viking://session/{session_id} +``` + +- 本阶段不保留旧 session 根路径兼容读 +- 迁移完成后,session 的 list / get / delete / add-message / commit 全部只认新路径 + +### 8.3 agent scope + +- 本次不支持旧 agent 数据自动迁移到新命名空间 +- 不保留旧 hash namespace 的兼容访问 +- 不继续让 `memory.agent_scope_mode` 参与服务端命名空间决策 +- 如需保留旧 agent 数据,可在升级前自行使用现有 `ovpack` 做离线备份 + +原因: + +- 当前 agent root 是 hash space,而不是显式 `agent_id` +- hash 值不能从结果稳定反推出原始 `user_id / agent_id` +- 如果目标拓扑还涉及 agent 共享,会进一步引入多份旧数据合并问题 + +### 8.4 总体取舍 + +本次兼容策略总结如下: + +- `user`:天然兼容,不迁 +- `session`:直接 `mv` +- `agent`:不自动兼容,只提供 ovpack 导出 + +这个取舍的目的是把新命名空间模型尽快收敛为单一存储契约,而不是把 legacy hash 逻辑继续带入新实现。 + +--- + +## 九、风险与边界 + +### 9.1 混合参与者 session 的提取策略尚未定义 + +当一个 session 同时包含多个 user 和多个 assistant 时,后续 `commit / extract` 如何分流到对应 user / agent 作用域,是独立问题。 + +本次只解决: + +- 会话可以共享 +- 每条消息的参与者身份可追踪 +- 检索与目录可见性有稳定 owner 语义 + +### 9.2 session 共享不等于 participant ACL + +本方案采用 account 级共享 session。也就是说,同 account 内用户都可以看见 session。 + +如果未来需要“仅参与者可见”的 session,需要额外引入 participant ACL 模型,这不在本次范围内。 + +### 9.3 拓扑切换不可在线修改 + +一旦 account 已有数据,切换 `isolate_user_scope_by_agent` 或 `isolate_agent_scope_by_user` 会导致: + +- 目录 root 变化 +- 索引字段变化 +- 检索过滤条件变化 + +因此本阶段不支持在线修改。 + +--- + +## 十、测试方案 + +### 10.1 account policy + +- 创建 account 时两项配置写入成功 +- list account 能返回两项配置 +- 默认值为 `false / false` +- 四种组合可正确加载 + +### 10.2 namespace matrix + +- `user` / `agent` canonical URI 生成正确 +- 简写 URI 展开正确 +- 四种拓扑下底层目录映射正确 +- 中间容器目录与真实作用域根区分正确 + +### 10.3 session + +- session 根路径为 `viking://session/{session_id}` +- session list 为 account 级 +- `role_id` 校验正确 +- `Message.role_id` 持久化正确 +- `participant_user_ids` / `participant_agent_ids` 正确累积 +- `ADMIN / ROOT` 删除权限正确 + +### 10.4 retrieval / visibility + +- FS `ls / stat / read` 与检索结果一致 +- user scope 在四种 policy 下可见性正确 +- agent scope 在四种 policy 下可见性正确 +- session scope 按 account 共享 +- cross-account 不泄漏 + +### 10.5 negative cases + +- 缺失 `role_id` +- 非法 nested URI +- policy 与路径不匹配 +- 尝试访问非本 user / agent 可见作用域 + +--- + +## 十一、推荐落地顺序 + +建议按以下顺序实施: + +1. 引入 account policy 与统一 namespace resolver +2. 改造 `UserIdentifier`、`RequestContext`、Admin API +3. 改造 `VikingFS` 与目录初始化 +4. 改造 `session` 路径与 `Message.role_id` +5. 改造 `Context`、embedding、vector schema 与过滤逻辑 +6. 最后更新 SDK / CLI 与文档 + +这样可以先把“路径与身份语义”固定,再去改造检索与接入层,风险更可控。 + +--- + +## 结论 + +本方案的核心是把 `user`、`agent` 的共享关系显式提升为 account 级策略,并将 `session` 升级为 account 级共享容器,再用 `role_id` 和标准 URI 把消息、目录和检索统一到同一套身份模型上。 + +如果接受这份方案,后续实现应严格遵守两条主线: + +- 所有 URI 先 canonicalize,再进入存储、检索、权限判断 +- 所有可见性与索引判断都基于 `account_id + uri + owner_user_id + owner_agent_id` + +这两条一旦立住,后续 mixed session extraction、participant ACL、审计追踪才有稳定的基础。 diff --git a/examples/multi_tenant/shared_session_role_id_http.py b/examples/multi_tenant/shared_session_role_id_http.py new file mode 100644 index 000000000..c262197df --- /dev/null +++ b/examples/multi_tenant/shared_session_role_id_http.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +""" +HTTP demo for shared-session + role_id semantics. + +This script creates 4 accounts for the namespace-policy matrix, creates one +regular USER in each account, then runs two scenarios per account: + +1. `multi-user` + Uses an ADMIN key to switch effective user/agent context within one account, + and demonstrates that ADMIN may explicitly pass role_id. + +2. `normal-user` + Uses a USER key to show that role_id is derived from request context and + explicit role_id is rejected. + +Create account examples for the 4 namespace-policy combinations: + export URL=http://127.0.0.1:1933 + export ROOT_KEY= + + # ff: isolate_user_scope_by_agent=false, isolate_agent_scope_by_user=false + curl -sS -X POST "$URL/api/v1/admin/accounts" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "account_id": "demo-ff", + "admin_user_id": "alice", + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": false + }' + + # ft: isolate_user_scope_by_agent=false, isolate_agent_scope_by_user=true + curl -sS -X POST "$URL/api/v1/admin/accounts" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "account_id": "demo-ft", + "admin_user_id": "alice", + "isolate_user_scope_by_agent": false, + "isolate_agent_scope_by_user": true + }' + + # tf: isolate_user_scope_by_agent=true, isolate_agent_scope_by_user=false + curl -sS -X POST "$URL/api/v1/admin/accounts" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "account_id": "demo-tf", + "admin_user_id": "alice", + "isolate_user_scope_by_agent": true, + "isolate_agent_scope_by_user": false + }' + + # tt: isolate_user_scope_by_agent=true, isolate_agent_scope_by_user=true + curl -sS -X POST "$URL/api/v1/admin/accounts" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "account_id": "demo-tt", + "admin_user_id": "alice", + "isolate_user_scope_by_agent": true, + "isolate_agent_scope_by_user": true + }' + +Create a regular USER for each account (the returned `result.user_key` is the key +to use with the `normal-user` scenario): + curl -sS -X POST "$URL/api/v1/admin/accounts/demo-ff/users" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "bob", "role": "user"}' + + curl -sS -X POST "$URL/api/v1/admin/accounts/demo-ft/users" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "bob", "role": "user"}' + + curl -sS -X POST "$URL/api/v1/admin/accounts/demo-tf/users" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "bob", "role": "user"}' + + curl -sS -X POST "$URL/api/v1/admin/accounts/demo-tt/users" \ + -H "X-API-Key: $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "bob", "role": "user"}' + +Examples: + python examples/multi_tenant/shared_session_role_id_http.py \ + --url http://127.0.0.1:1933 \ + --root-key +""" + +from __future__ import annotations + +import argparse +import json +import sys +import uuid +from typing import Any, Dict, Optional + +import httpx + +OK = "[OK]" +FAIL = "[FAIL]" +UNSET = object() +NAMESPACE_COMBINATIONS = [ + ("ff", False, False), + ("ft", False, True), + ("tf", True, False), + ("tt", True, True), +] + + +def build_headers( + *, + api_key: str, + account: Optional[str] = None, + user: Optional[str] = None, + agent: Optional[str] = None, +) -> Dict[str, str]: + headers = { + "X-API-Key": api_key, + "Content-Type": "application/json", + } + if account: + headers["X-OpenViking-Account"] = account + if user: + headers["X-OpenViking-User"] = user + if agent: + headers["X-OpenViking-Agent"] = agent + return headers + + +def decode_json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text + + +def print_response(label: str, response: httpx.Response) -> None: + payload = decode_json(response) + print(f"{label}: HTTP {response.status_code}") + if isinstance(payload, (dict, list)): + print(json.dumps(payload, indent=2, ensure_ascii=False)) + else: + print(payload) + print() + + +def expect_status(response: httpx.Response, expected: int, label: str) -> None: + if response.status_code != expected: + print_response(f"{FAIL} {label}", response) + raise SystemExit(1) + print(f"{OK} {label}: HTTP {response.status_code}") + + +def create_session( + client: httpx.Client, + base_url: str, + headers: Dict[str, str], + session_id: Optional[str] = None, +) -> str: + payload: Dict[str, Any] = {} + if session_id: + payload["session_id"] = session_id + response = client.post(f"{base_url}/api/v1/sessions", headers=headers, json=payload) + expect_status(response, 200, "create session") + return response.json()["result"]["session_id"] + + +def create_account( + client: httpx.Client, + base_url: str, + *, + root_key: str, + account_id: str, + admin_user_id: str, + isolate_user_scope_by_agent: bool, + isolate_agent_scope_by_user: bool, +) -> Dict[str, Any]: + response = client.post( + f"{base_url}/api/v1/admin/accounts", + headers=build_headers(api_key=root_key), + json={ + "account_id": account_id, + "admin_user_id": admin_user_id, + "isolate_user_scope_by_agent": isolate_user_scope_by_agent, + "isolate_agent_scope_by_user": isolate_agent_scope_by_user, + }, + ) + expect_status(response, 200, f"create account {account_id}") + return response.json()["result"] + + +def register_user( + client: httpx.Client, + base_url: str, + *, + api_key: str, + account_id: str, + user_id: str, + role: str = "user", +) -> Dict[str, Any]: + response = client.post( + f"{base_url}/api/v1/admin/accounts/{account_id}/users", + headers=build_headers(api_key=api_key), + json={"user_id": user_id, "role": role}, + ) + expect_status(response, 200, f"register user {user_id} in {account_id}") + return response.json()["result"] + + +def add_message( + client: httpx.Client, + base_url: str, + session_id: str, + headers: Dict[str, str], + *, + role: str, + content: str, + role_id: object = UNSET, +) -> httpx.Response: + payload: Dict[str, Any] = { + "role": role, + "content": content, + } + if role_id is not UNSET: + payload["role_id"] = role_id + return client.post( + f"{base_url}/api/v1/sessions/{session_id}/messages", + headers=headers, + json=payload, + ) + + +def get_context( + client: httpx.Client, + base_url: str, + session_id: str, + headers: Dict[str, str], +) -> httpx.Response: + return client.get( + f"{base_url}/api/v1/sessions/{session_id}/context", + headers=headers, + ) + + +def run_multi_user_flow( + client: httpx.Client, + *, + base_url: str, + account: str, + admin_key: str, + effective_user_a: str, + effective_agent_a: str, + effective_user_b: str, + effective_agent_b: str, + assistant_role_id: Optional[str], + session_id: Optional[str], +) -> Dict[str, Any]: + admin_headers_a = build_headers( + api_key=admin_key, + account=account, + user=effective_user_a, + agent=effective_agent_a, + ) + admin_headers_b = build_headers( + api_key=admin_key, + account=account, + user=effective_user_b, + agent=effective_agent_b, + ) + + session_id = create_session( + client, + base_url, + admin_headers_a, + session_id=session_id, + ) + print(f"{OK} session_id = {session_id}") + print() + + response = add_message( + client, + base_url, + session_id, + admin_headers_a, + role="user", + content=f"implicit actor from effective context: {effective_user_a}", + ) + expect_status(response, 200, "admin implicit user role_id fill") + + response = add_message( + client, + base_url, + session_id, + admin_headers_a, + role="user", + content=f"explicit actor set by admin: {effective_user_b}", + role_id=effective_user_b, + ) + expect_status(response, 200, "admin explicit user role_id") + + resolved_assistant_role_id = assistant_role_id or effective_agent_b + response = add_message( + client, + base_url, + session_id, + admin_headers_a, + role="assistant", + content=f"explicit assistant actor: {resolved_assistant_role_id}", + role_id=resolved_assistant_role_id, + ) + expect_status(response, 200, "admin explicit assistant role_id") + + context_from_other_user = get_context( + client, + base_url, + session_id, + admin_headers_b, + ) + expect_status(context_from_other_user, 200, "shared session visible from other user view") + print_response("shared session context", context_from_other_user) + return { + "session_id": session_id, + "context": decode_json(context_from_other_user), + } + + +def run_normal_user_flow( + client: httpx.Client, + *, + base_url: str, + user_key: str, + agent: str, + account: Optional[str], + user: Optional[str], + explicit_role_id: Optional[str], + expected_error_status: int, + session_id: Optional[str], +) -> Dict[str, Any]: + user_headers = build_headers( + api_key=user_key, + account=account, + user=user, + agent=agent, + ) + + session_id = create_session( + client, + base_url, + user_headers, + session_id=session_id, + ) + print(f"{OK} session_id = {session_id}") + print() + + response = add_message( + client, + base_url, + session_id, + user_headers, + role="user", + content="implicit actor from user key context", + ) + expect_status(response, 200, "user implicit role_id fill") + + response = add_message( + client, + base_url, + session_id, + user_headers, + role="assistant", + content=f"implicit assistant actor from agent context: {agent}", + ) + expect_status(response, 200, "user implicit assistant role_id fill") + + resolved_explicit_role_id = explicit_role_id or "explicit-user" + response = add_message( + client, + base_url, + session_id, + user_headers, + role="user", + content=f"this should fail with explicit role_id: {resolved_explicit_role_id}", + role_id=resolved_explicit_role_id, + ) + if response.status_code == expected_error_status: + print_response( + f"{OK} user explicit role_id rejected as expected", + response, + ) + else: + print_response( + f"{FAIL} user explicit role_id should have been rejected", + response, + ) + raise SystemExit(1) + + context_response = get_context(client, base_url, session_id, user_headers) + expect_status(context_response, 200, "load session context") + print_response("normal user session context", context_response) + return { + "session_id": session_id, + "context": decode_json(context_response), + } + + +def run_setup_and_run(args: argparse.Namespace) -> Dict[str, Any]: + base_url = args.url.rstrip("/") + run_id = args.run_id or uuid.uuid4().hex[:8] + name_prefix = args.name_prefix or f"demo-auto-{run_id}" + summary: Dict[str, Any] = { + "run_id": run_id, + "name_prefix": name_prefix, + "accounts": [], + } + + with httpx.Client(timeout=args.timeout) as client: + health = client.get(f"{base_url}/health") + expect_status(health, 200, "health check") + + for ( + suffix, + isolate_user_scope_by_agent, + isolate_agent_scope_by_user, + ) in NAMESPACE_COMBINATIONS: + account_id = f"{name_prefix}-{suffix}" + print(f"== Account {account_id} ==") + account_result = create_account( + client, + base_url, + root_key=args.root_key, + account_id=account_id, + admin_user_id=args.admin_user, + isolate_user_scope_by_agent=isolate_user_scope_by_agent, + isolate_agent_scope_by_user=isolate_agent_scope_by_user, + ) + user_result = register_user( + client, + base_url, + api_key=args.root_key, + account_id=account_id, + user_id=args.regular_user, + role="user", + ) + + multi_session_id = f"{account_id}-multi-{run_id}" + normal_session_id = f"{account_id}-normal-{run_id}" + + print(f"-- multi-user scenario for {account_id} --") + multi_result = run_multi_user_flow( + client, + base_url=base_url, + account=account_id, + admin_key=account_result["user_key"], + effective_user_a=args.admin_user, + effective_agent_a=args.effective_agent_a, + effective_user_b=args.regular_user, + effective_agent_b=args.effective_agent_b, + assistant_role_id=args.assistant_role_id, + session_id=multi_session_id, + ) + + print(f"-- normal-user scenario for {account_id} --") + normal_result = run_normal_user_flow( + client, + base_url=base_url, + user_key=user_result["user_key"], + agent=args.normal_user_agent, + account=None, + user=None, + explicit_role_id=args.explicit_role_id, + expected_error_status=args.expected_error_status, + session_id=normal_session_id, + ) + + summary["accounts"].append( + { + "account_id": account_id, + "namespace_policy": { + "isolate_user_scope_by_agent": isolate_user_scope_by_agent, + "isolate_agent_scope_by_user": isolate_agent_scope_by_user, + }, + "admin_user_id": args.admin_user, + "admin_key": account_result["user_key"], + "regular_user_id": args.regular_user, + "regular_user_key": user_result["user_key"], + "multi_user": multi_result, + "normal_user": normal_result, + } + ) + print() + + print("== Summary ==") + print(json.dumps(summary, indent=2, ensure_ascii=False)) + return summary + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Shared session + role_id HTTP demo") + parser.add_argument( + "--url", + default="http://127.0.0.1:1933", + help="OpenViking server base URL", + ) + parser.add_argument( + "--timeout", + type=float, + default=30.0, + help="HTTP timeout in seconds", + ) + parser.add_argument("--root-key", required=True, help="ROOT API key") + parser.add_argument( + "--name-prefix", + default=None, + help="Account prefix. Defaults to demo-auto-", + ) + parser.add_argument( + "--run-id", + default=None, + help="Optional fixed run id used in account/session naming", + ) + parser.add_argument( + "--admin-user", + default="alice", + help="Admin user created in every account", + ) + parser.add_argument( + "--regular-user", + default="bob", + help="Regular USER created in every account", + ) + parser.add_argument( + "--effective-agent-a", + default="agent-a", + help="Agent used for ADMIN effective context A", + ) + parser.add_argument( + "--effective-agent-b", + default="agent-b", + help="Agent used for ADMIN effective context B", + ) + parser.add_argument( + "--normal-user-agent", + default="agent-a", + help="Agent used for USER scenario", + ) + parser.add_argument( + "--assistant-role-id", + default=None, + help="Optional explicit assistant role_id for the ADMIN scenario", + ) + parser.add_argument( + "--explicit-role-id", + default=None, + help="Optional explicit role_id used for the USER negative test", + ) + parser.add_argument( + "--expected-error-status", + type=int, + default=400, + help="Expected HTTP status when USER explicitly sends role_id", + ) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + run_setup_and_run(args) + except httpx.HTTPError as exc: + print(f"{FAIL} HTTP error: {exc}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openviking/async_client.py b/openviking/async_client.py index ab56d520d..0c509d388 100644 --- a/openviking/async_client.py +++ b/openviking/async_client.py @@ -172,6 +172,7 @@ async def add_message( content: str | None = None, parts: list[dict] | None = None, created_at: str | None = None, + role_id: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -181,12 +182,18 @@ async def add_message( content: Text content (simple mode) parts: Parts array (full Part support: TextPart, ContextPart, ToolPart) created_at: Message creation time (ISO format string) + role_id: Optional explicit actor identity. Omit to let the client/server derive it. If both content and parts are provided, parts takes precedence. """ await self._ensure_initialized() return await self._client.add_message( - session_id=session_id, role=role, content=content, parts=parts, created_at=created_at + session_id=session_id, + role=role, + content=content, + parts=parts, + created_at=created_at, + role_id=role_id, ) async def commit_session( diff --git a/openviking/client/local.py b/openviking/client/local.py index 21735e990..dbb38164c 100644 --- a/openviking/client/local.py +++ b/openviking/client/local.py @@ -455,6 +455,7 @@ async def add_message( content: Optional[str] = None, parts: Optional[List[Dict[str, Any]]] = None, created_at: Optional[str] = None, + role_id: Optional[str] = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -464,13 +465,13 @@ async def add_message( content: Text content (simple mode, backward compatible) parts: Parts array (full Part support mode) created_at: Message creation time (ISO format string) + role_id: Optional explicit actor identity. Omit to derive it from the local context. If both content and parts are provided, parts takes precedence. """ from openviking.message.part import Part, TextPart, part_from_dict - session = self._service.sessions.session(self._ctx, session_id) - await session.load() + session = await self._service.sessions.get(session_id, self._ctx, auto_create=True) message_parts: list[Part] if parts is not None: @@ -480,8 +481,12 @@ async def add_message( else: raise ValueError("Either content or parts must be provided") - # created_at 直接传递给 session (毫秒时间戳) - session.add_message(role, message_parts, created_at=created_at) + if role_id is None and role == "user": + role_id = self._ctx.user.user_id + elif role_id is None and role == "assistant": + role_id = self._ctx.user.agent_id + + session.add_message(role, message_parts, role_id=role_id, created_at=created_at) return { "session_id": session_id, "message_count": len(session.messages), @@ -520,11 +525,12 @@ def session(self, session_id: Optional[str] = None, must_exist: bool = False) -> Returns: Session object if exists, None otherwise. """ - + session = self._service.sessions.session(self._ctx, session_id) if not run_async(session.exists()): if must_exist and session_id: from openviking_cli.exceptions import NotFoundError + raise NotFoundError(session_id, "session") else: run_async(session.ensure_exists()) diff --git a/openviking/client/session.py b/openviking/client/session.py index 27b6b33b6..1c6bd2f24 100644 --- a/openviking/client/session.py +++ b/openviking/client/session.py @@ -41,6 +41,7 @@ async def add_message( content: Optional[str] = None, parts: Optional[List[Part]] = None, created_at: Optional[str] = None, + role_id: Optional[str] = None, ) -> Dict[str, Any]: """Add a message to the session. @@ -49,6 +50,7 @@ async def add_message( content: Text content (simple mode) parts: Parts list (TextPart, ContextPart, ToolPart) created_at: Message creation time (ISO format string). If not provided, current time is used. + role_id: Optional explicit actor identity. Omit to let the server derive it. If both content and parts are provided, parts takes precedence. @@ -58,10 +60,18 @@ async def add_message( if parts is not None: parts_dicts = [asdict(p) for p in parts] return await self._client.add_message( - self.session_id, role, parts=parts_dicts, created_at=created_at + self.session_id, + role, + parts=parts_dicts, + created_at=created_at, + role_id=role_id, ) return await self._client.add_message( - self.session_id, role, content=content, created_at=created_at + self.session_id, + role, + content=content, + created_at=created_at, + role_id=role_id, ) async def commit(self, telemetry: TelemetryRequest = False) -> Dict[str, Any]: diff --git a/openviking/console/static/app.js b/openviking/console/static/app.js index ef919e5fb..1f06840e2 100644 --- a/openviking/console/static/app.js +++ b/openviking/console/static/app.js @@ -2337,9 +2337,10 @@ function bindAddMemory() { } for (const msg of messages) { + const payload = { ...msg }; await callConsole(`/ov/sessions/${sessionId}/messages`, { method: "POST", - body: JSON.stringify(msg), + body: JSON.stringify(payload), }); } diff --git a/openviking/core/__init__.py b/openviking/core/__init__.py index 1c33e6c0a..1baea54b3 100644 --- a/openviking/core/__init__.py +++ b/openviking/core/__init__.py @@ -2,14 +2,35 @@ # SPDX-License-Identifier: AGPL-3.0 """Core context abstractions for OpenViking.""" -from openviking.core.building_tree import BuildingTree -from openviking.core.context import Context, ResourceContentType -from openviking.core.directories import ( - PRESET_DIRECTORIES, - DirectoryDefinition, - DirectoryInitializer, -) -from openviking.core.skill_loader import SkillLoader +from importlib import import_module +from typing import Any + +_EXPORTS = { + "BuildingTree": ("openviking.core.building_tree", "BuildingTree"), + "Context": ("openviking.core.context", "Context"), + "ContextType": ("openviking.core.context", "ContextType"), + "ResourceContentType": ("openviking.core.context", "ResourceContentType"), + "SkillLoader": ("openviking.core.skill_loader", "SkillLoader"), + "DirectoryDefinition": ("openviking.core.directories", "DirectoryDefinition"), + "PRESET_DIRECTORIES": ("openviking.core.directories", "PRESET_DIRECTORIES"), + "DirectoryInitializer": ("openviking.core.directories", "DirectoryInitializer"), +} + + +def __getattr__(name: str) -> Any: + try: + module_name, attr_name = _EXPORTS[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + + value = getattr(import_module(module_name), attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(__all__)) + __all__ = [ # Context diff --git a/openviking/core/context.py b/openviking/core/context.py index 55bce1c47..612437bde 100644 --- a/openviking/core/context.py +++ b/openviking/core/context.py @@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional from uuid import uuid4 +from openviking.core.namespace import owner_fields_for_uri from openviking.utils.time_utils import format_iso8601, parse_iso_datetime from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils.uri import VikingURI @@ -71,6 +72,8 @@ def __init__( session_id: Optional[str] = None, user: Optional[UserIdentifier] = None, account_id: Optional[str] = None, + owner_user_id: Optional[str] = None, + owner_agent_id: Optional[str] = None, owner_space: Optional[str] = None, id: Optional[str] = None, ): @@ -97,6 +100,17 @@ def __init__( self.session_id = session_id self.user = user self.account_id = account_id or (user.account_id if user else "default") + owner_fields = owner_fields_for_uri( + uri, + user=user, + account_id=self.account_id, + ) + self.owner_user_id = ( + owner_user_id if owner_user_id is not None else owner_fields["owner_user_id"] + ) + self.owner_agent_id = ( + owner_agent_id if owner_agent_id is not None else owner_fields["owner_agent_id"] + ) self.owner_space = owner_space or self._derive_owner_space(user) self.vector: Optional[List[float]] = None self.vectorize = Vectorize(abstract) @@ -106,9 +120,9 @@ def _derive_owner_space(self, user: Optional[UserIdentifier]) -> str: if not user: return "" if self.uri.startswith("viking://agent/"): - return user.agent_space_name() + return user.agent_id if self.uri.startswith("viking://user/") or self.uri.startswith("viking://session/"): - return user.user_space_name() + return user.user_id return "" def _derive_context_type(self) -> str: @@ -175,6 +189,8 @@ def to_dict(self) -> Dict[str, Any]: "related_uri": self.related_uri, "session_id": self.session_id, "account_id": self.account_id, + "owner_user_id": self.owner_user_id, + "owner_agent_id": self.owner_agent_id, "owner_space": self.owner_space, } if self.level is not None: @@ -235,6 +251,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "Context": session_id=data.get("session_id"), user=user_obj, account_id=data.get("account_id"), + owner_user_id=data.get("owner_user_id"), + owner_agent_id=data.get("owner_agent_id"), owner_space=data.get("owner_space"), ) obj.id = data.get("id", obj.id) diff --git a/openviking/core/directories.py b/openviking/core/directories.py index e0dedb0f0..3621c553c 100644 --- a/openviking/core/directories.py +++ b/openviking/core/directories.py @@ -11,11 +11,18 @@ from typing import TYPE_CHECKING, Dict, List, Optional from openviking.core.context import Context, ContextType, Vectorize +from openviking.core.namespace import ( + agent_space_fragment, + canonical_agent_root, + canonical_user_root, + user_space_fragment, +) from openviking.server.identity import RequestContext from openviking.storage.queuefs.embedding_msg_converter import EmbeddingMsgConverter if TYPE_CHECKING: from openviking.storage import VikingDBManager + from openviking.storage.viking_fs import VikingFS @dataclass @@ -143,8 +150,17 @@ class DirectoryInitializer: def __init__( self, vikingdb: "VikingDBManager", + viking_fs: Optional["VikingFS"] = None, ): self.vikingdb = vikingdb + self._viking_fs = viking_fs + + def _get_viking_fs(self) -> "VikingFS": + if self._viking_fs is not None: + return self._viking_fs + from openviking.storage.viking_fs import get_viking_fs + + return get_viking_fs() async def initialize_account_directories(self, ctx: RequestContext) -> int: """Initialize account-shared scope roots.""" @@ -172,11 +188,16 @@ async def initialize_user_directories(self, ctx: RequestContext) -> int: """Initialize user-space tree lazily for the current user.""" if "user" not in PRESET_DIRECTORIES: return 0 - user_space_root = f"viking://user/{ctx.user.user_space_name()}" + user_space_root = canonical_user_root(ctx) user_tree = PRESET_DIRECTORIES["user"] + parent_uri = "viking://user" + if ctx.namespace_policy.isolate_user_scope_by_agent: + container_uri = f"viking://user/{ctx.user.user_id}" + await self._ensure_container_directory(container_uri, parent_uri=parent_uri, ctx=ctx) + parent_uri = container_uri created = await self._ensure_directory( uri=user_space_root, - parent_uri="viking://user", + parent_uri=parent_uri, defn=user_tree, scope="user", ctx=ctx, @@ -191,11 +212,16 @@ async def initialize_agent_directories(self, ctx: RequestContext) -> int: """Initialize agent-space tree lazily for the current user+agent.""" if "agent" not in PRESET_DIRECTORIES: return 0 - agent_space_root = f"viking://agent/{ctx.user.agent_space_name()}" + agent_space_root = canonical_agent_root(ctx) agent_tree = PRESET_DIRECTORIES["agent"] + parent_uri = "viking://agent" + if ctx.namespace_policy.isolate_agent_scope_by_user: + container_uri = f"viking://agent/{ctx.user.agent_id}" + await self._ensure_container_directory(container_uri, parent_uri=parent_uri, ctx=ctx) + parent_uri = container_uri created = await self._ensure_directory( uri=agent_space_root, - parent_uri="viking://agent", + parent_uri=parent_uri, defn=agent_tree, scope="agent", ctx=ctx, @@ -207,6 +233,18 @@ async def initialize_agent_directories(self, ctx: RequestContext) -> int: return count + async def _ensure_container_directory( + self, + uri: str, + parent_uri: Optional[str], + ctx: RequestContext, + ) -> None: + """Ensure an intermediate namespace container exists without seeding vectors.""" + try: + await self._get_viking_fs().mkdir(uri, exist_ok=True, ctx=ctx) + except Exception: + pass + async def _ensure_directory( self, uri: str, @@ -282,17 +320,15 @@ async def _ensure_directory_l0_l1_vectors( @staticmethod def _owner_space_for_scope(scope: str, ctx: RequestContext) -> str: if scope in {"user", "session"}: - return ctx.user.user_space_name() + return user_space_fragment(ctx) if scope == "agent": - return ctx.user.agent_space_name() + return agent_space_fragment(ctx) return "" async def _check_agfs_files_exist(self, uri: str, ctx: RequestContext) -> bool: """Check if L0/L1 files exist in AGFS.""" - from openviking.storage.viking_fs import get_viking_fs - try: - viking_fs = get_viking_fs() + viking_fs = self._get_viking_fs() await viking_fs.abstract(uri, ctx=ctx) return True except Exception: @@ -330,9 +366,7 @@ async def _create_agfs_structure( self, uri: str, abstract: str, overview: str, ctx: RequestContext ) -> None: """Create L0/L1 file structure for directory in AGFS.""" - from openviking.storage.viking_fs import get_viking_fs - - await get_viking_fs().write_context( + await self._get_viking_fs().write_context( uri=uri, abstract=abstract, overview=overview, diff --git a/openviking/core/namespace.py b/openviking/core/namespace.py new file mode 100644 index 000000000..213099817 --- /dev/null +++ b/openviking/core/namespace.py @@ -0,0 +1,328 @@ +"""Namespace policy helpers for account/user/agent/session URIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from openviking.server.identity import AccountNamespacePolicy, RequestContext +from openviking_cli.utils.uri import VikingURI + +_USER_SHORTHAND_SEGMENTS = {"memories", "profile.md", ".abstract.md", ".overview.md"} +_AGENT_SHORTHAND_SEGMENTS = { + "memories", + "skills", + "instructions", + "workspaces", + ".abstract.md", + ".overview.md", +} + + +class NamespaceShapeError(ValueError): + """Raised when a URI does not match the active namespace policy shape.""" + + +@dataclass(frozen=True) +class ResolvedNamespace: + """Canonicalized namespace information for a URI.""" + + uri: str + scope: str + owner_user_id: Optional[str] = None + owner_agent_id: Optional[str] = None + is_container: bool = False + + +def _uri_parts(uri: str) -> list[str]: + normalized = VikingURI.normalize(uri).rstrip("/") + if normalized == "viking:": + normalized = "viking://" + if normalized == "viking://": + return [] + return [part for part in normalized[len("viking://") :].split("/") if part] + + +def canonical_user_root(ctx: RequestContext) -> str: + return f"viking://user/{user_space_fragment(ctx)}" + + +def user_space_fragment(ctx: RequestContext) -> str: + if ctx.namespace_policy.isolate_user_scope_by_agent: + return f"{ctx.user.user_id}/agent/{ctx.user.agent_id}" + return ctx.user.user_id + + +def canonical_agent_root(ctx: RequestContext) -> str: + return f"viking://agent/{agent_space_fragment(ctx)}" + + +def agent_space_fragment(ctx: RequestContext) -> str: + if ctx.namespace_policy.isolate_agent_scope_by_user: + return f"{ctx.user.agent_id}/user/{ctx.user.user_id}" + return ctx.user.agent_id + + +def canonical_session_uri(session_id: Optional[str] = None) -> str: + if not session_id: + return "viking://session" + return f"viking://session/{session_id}" + + +def visible_roots(ctx: RequestContext) -> list[str]: + return [ + "viking://resources", + "viking://session", + canonical_user_root(ctx), + canonical_agent_root(ctx), + ] + + +def resolve_uri( + uri: str, + ctx: Optional[RequestContext] = None, + *, + require_canonical: bool = False, +) -> ResolvedNamespace: + """Resolve a URI into a canonical URI and owner tuple.""" + + parts = _uri_parts(uri) + if not parts: + return ResolvedNamespace(uri="viking://", scope="", is_container=True) + + scope = parts[0] + if scope == "user": + return _resolve_user_uri(parts, ctx=ctx, require_canonical=require_canonical) + if scope == "agent": + return _resolve_agent_uri(parts, ctx=ctx, require_canonical=require_canonical) + if scope == "session": + return _resolve_session_uri(parts) + if scope in {"resources", "temp", "queue"}: + return ResolvedNamespace(uri=VikingURI.normalize(uri).rstrip("/"), scope=scope) + return ResolvedNamespace(uri=VikingURI.normalize(uri).rstrip("/"), scope=scope) + + +def canonicalize_uri(uri: str, ctx: Optional[RequestContext] = None) -> str: + return resolve_uri(uri, ctx=ctx).uri + + +def is_accessible(uri: str, ctx: RequestContext) -> bool: + if getattr(ctx.role, "value", ctx.role) == "root": + return True + + try: + target = resolve_uri(uri, ctx=ctx) + except NamespaceShapeError: + return False + + if target.scope in {"", "resources", "temp", "queue", "session"}: + return True + if target.scope == "user": + if target.owner_user_id and target.owner_user_id != ctx.user.user_id: + return False + if ( + ctx.namespace_policy.isolate_user_scope_by_agent + and target.owner_agent_id is not None + and target.owner_agent_id != ctx.user.agent_id + ): + return False + return True + if target.scope == "agent": + if target.owner_agent_id and target.owner_agent_id != ctx.user.agent_id: + return False + if ( + ctx.namespace_policy.isolate_agent_scope_by_user + and target.owner_user_id is not None + and target.owner_user_id != ctx.user.user_id + ): + return False + return True + return True + + +def owner_fields_for_uri( + uri: str, + ctx: Optional[RequestContext] = None, + *, + user=None, + account_id: Optional[str] = None, + policy: Optional[AccountNamespacePolicy] = None, +) -> dict: + resolved_ctx = ctx + if resolved_ctx is None and user is not None: + from openviking.server.identity import Role + + resolved_ctx = RequestContext( + user=user, + role=Role.ROOT, + namespace_policy=policy or AccountNamespacePolicy(), + ) + if resolved_ctx is None and account_id: + from openviking.server.identity import Role + from openviking_cli.session.user_id import UserIdentifier + + resolved_ctx = RequestContext( + user=UserIdentifier(account_id, "default", "default"), + role=Role.ROOT, + namespace_policy=policy or AccountNamespacePolicy(), + ) + + try: + resolved = resolve_uri(uri, ctx=resolved_ctx) + except NamespaceShapeError: + return { + "uri": VikingURI.normalize(uri).rstrip("/"), + "owner_user_id": None, + "owner_agent_id": None, + } + return { + "uri": resolved.uri, + "owner_user_id": resolved.owner_user_id, + "owner_agent_id": resolved.owner_agent_id, + } + + +def _resolve_user_uri( + parts: list[str], + ctx: Optional[RequestContext], + *, + require_canonical: bool, +) -> ResolvedNamespace: + normalized = "viking://" + "/".join(parts) + if len(parts) == 1: + return ResolvedNamespace(uri="viking://user", scope="user", is_container=True) + + second = parts[1] + if second in _USER_SHORTHAND_SEGMENTS: + if require_canonical: + raise NamespaceShapeError(f"Shorthand user URI is not allowed here: {normalized}") + if ctx is None: + raise NamespaceShapeError(f"User shorthand URI requires request context: {normalized}") + suffix = parts[1:] + return resolve_uri( + "/".join([canonical_user_root(ctx)[len("viking://") :], *suffix]), ctx=ctx + ) + + user_id = second + policy = _require_policy(ctx) + if len(parts) == 2: + if policy.isolate_user_scope_by_agent: + return ResolvedNamespace( + uri=f"viking://user/{user_id}", + scope="user", + owner_user_id=user_id, + is_container=True, + ) + return ResolvedNamespace( + uri=f"viking://user/{user_id}", + scope="user", + owner_user_id=user_id, + ) + + if policy.isolate_user_scope_by_agent: + if len(parts) < 4 or parts[2] != "agent": + raise NamespaceShapeError( + f"User URI must include /agent/{{agent_id}} under current policy: {normalized}" + ) + agent_id = parts[3] + suffix = parts[4:] + canonical = f"viking://user/{user_id}/agent/{agent_id}" + if suffix: + canonical = f"{canonical}/{'/'.join(suffix)}" + return ResolvedNamespace( + uri=canonical, + scope="user", + owner_user_id=user_id, + owner_agent_id=agent_id, + ) + + suffix = parts[2:] + canonical = f"viking://user/{user_id}" + if suffix: + canonical = f"{canonical}/{'/'.join(suffix)}" + return ResolvedNamespace( + uri=canonical, + scope="user", + owner_user_id=user_id, + ) + + +def _resolve_agent_uri( + parts: list[str], + ctx: Optional[RequestContext], + *, + require_canonical: bool, +) -> ResolvedNamespace: + normalized = "viking://" + "/".join(parts) + if len(parts) == 1: + return ResolvedNamespace(uri="viking://agent", scope="agent", is_container=True) + + second = parts[1] + if second in _AGENT_SHORTHAND_SEGMENTS: + if require_canonical: + raise NamespaceShapeError(f"Shorthand agent URI is not allowed here: {normalized}") + if ctx is None: + raise NamespaceShapeError(f"Agent shorthand URI requires request context: {normalized}") + suffix = parts[1:] + return resolve_uri( + "/".join([canonical_agent_root(ctx)[len("viking://") :], *suffix]), ctx=ctx + ) + + agent_id = second + policy = _require_policy(ctx) + if len(parts) == 2: + if policy.isolate_agent_scope_by_user: + return ResolvedNamespace( + uri=f"viking://agent/{agent_id}", + scope="agent", + owner_agent_id=agent_id, + is_container=True, + ) + return ResolvedNamespace( + uri=f"viking://agent/{agent_id}", + scope="agent", + owner_agent_id=agent_id, + ) + + if policy.isolate_agent_scope_by_user: + if len(parts) < 4 or parts[2] != "user": + raise NamespaceShapeError( + f"Agent URI must include /user/{{user_id}} under current policy: {normalized}" + ) + user_id = parts[3] + suffix = parts[4:] + canonical = f"viking://agent/{agent_id}/user/{user_id}" + if suffix: + canonical = f"{canonical}/{'/'.join(suffix)}" + return ResolvedNamespace( + uri=canonical, + scope="agent", + owner_user_id=user_id, + owner_agent_id=agent_id, + ) + + suffix = parts[2:] + canonical = f"viking://agent/{agent_id}" + if suffix: + canonical = f"{canonical}/{'/'.join(suffix)}" + return ResolvedNamespace( + uri=canonical, + scope="agent", + owner_agent_id=agent_id, + ) + + +def _resolve_session_uri(parts: list[str]) -> ResolvedNamespace: + if len(parts) == 1: + return ResolvedNamespace(uri="viking://session", scope="session", is_container=True) + session_id = parts[1] + canonical = f"viking://session/{session_id}" + if len(parts) > 2: + canonical = f"{canonical}/{'/'.join(parts[2:])}" + return ResolvedNamespace(uri=canonical, scope="session") + + +def _require_policy(ctx: Optional[RequestContext]) -> AccountNamespacePolicy: + if ctx is None: + return AccountNamespacePolicy() + return ctx.namespace_policy diff --git a/openviking/message/message.py b/openviking/message/message.py index b14867f52..c0cba66f6 100644 --- a/openviking/message/message.py +++ b/openviking/message/message.py @@ -20,6 +20,7 @@ class Message: id: str role: Literal["user", "assistant"] parts: List[Part] + role_id: Optional[str] = None created_at: str = None @property @@ -73,6 +74,7 @@ def to_dict(self) -> dict: return { "id": self.id, "role": self.role, + "role_id": self.role_id, "parts": [self._part_to_dict(p) for p in self.parts], "created_at": created_at_val, } @@ -151,11 +153,17 @@ def from_dict(cls, data: dict) -> "Message": id=data["id"], role=data["role"], parts=parts, + role_id=data.get("role_id"), created_at=data.get("created_at"), ) @classmethod - def create_user(cls, content: str, msg_id: str = None) -> "Message": + def create_user( + cls, + content: str, + msg_id: str = None, + role_id: Optional[str] = None, + ) -> "Message": """Create user message.""" from uuid import uuid4 @@ -163,6 +171,7 @@ def create_user(cls, content: str, msg_id: str = None) -> "Message": id=msg_id or f"msg_{uuid4().hex}", role="user", parts=[TextPart(text=content)], + role_id=role_id, created_at=datetime.now(timezone.utc).isoformat(), ) @@ -173,6 +182,7 @@ def create_assistant( context_refs: List[dict] = None, tool_calls: List[dict] = None, msg_id: str = None, + role_id: Optional[str] = None, ) -> "Message": """Create assistant message.""" from uuid import uuid4 @@ -206,6 +216,7 @@ def create_assistant( id=msg_id or f"msg_{uuid4().hex}", role="assistant", parts=parts, + role_id=role_id, created_at=datetime.now(timezone.utc).isoformat(), ) diff --git a/openviking/retrieve/hierarchical_retriever.py b/openviking/retrieve/hierarchical_retriever.py index 5c7419200..338caf8ec 100644 --- a/openviking/retrieve/hierarchical_retriever.py +++ b/openviking/retrieve/hierarchical_retriever.py @@ -14,6 +14,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple +from openviking.core.namespace import canonical_agent_root, canonical_user_root from openviking.models.embedder.base import EmbedResult, embed_compat from openviking.models.rerank import RerankClient from openviking.retrieve.memory_lifecycle import hotness_score @@ -596,22 +597,22 @@ def _get_root_uris_for_type( if not ctx or ctx.role == Role.ROOT: return [] - user_space = ctx.user.user_space_name() - agent_space = ctx.user.agent_space_name() + user_root = canonical_user_root(ctx) + agent_root = canonical_agent_root(ctx) if context_type is None: return [ - f"viking://user/{user_space}/memories", - f"viking://agent/{agent_space}/memories", + f"{user_root}/memories", + f"{agent_root}/memories", "viking://resources", - f"viking://agent/{agent_space}/skills", + f"{agent_root}/skills", ] elif context_type == ContextType.MEMORY: return [ - f"viking://user/{user_space}/memories", - f"viking://agent/{agent_space}/memories", + f"{user_root}/memories", + f"{agent_root}/memories", ] elif context_type == ContextType.RESOURCE: return ["viking://resources"] elif context_type == ContextType.SKILL: - return [f"viking://agent/{agent_space}/skills"] + return [f"{agent_root}/skills"] return [] diff --git a/openviking/server/api_keys.py b/openviking/server/api_keys.py index e252454af..a5b54c9c4 100644 --- a/openviking/server/api_keys.py +++ b/openviking/server/api_keys.py @@ -12,7 +12,7 @@ from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError -from openviking.server.identity import ResolvedIdentity, Role +from openviking.server.identity import AccountNamespacePolicy, ResolvedIdentity, Role from openviking.storage.viking_fs import VikingFS from openviking_cli.exceptions import ( AlreadyExistsError, @@ -25,6 +25,7 @@ ACCOUNTS_PATH = "/local/_system/accounts.json" USERS_PATH_TEMPLATE = "/local/{account_id}/_system/users.json" +SETTINGS_PATH_TEMPLATE = "/local/{account_id}/_system/setting.json" # Argon2id parameters @@ -51,6 +52,7 @@ class AccountInfo: created_at: str users: Dict[str, dict] = field(default_factory=dict) + namespace_policy: AccountNamespacePolicy = field(default_factory=AccountNamespacePolicy) class APIKeyManager: @@ -64,7 +66,12 @@ class APIKeyManager: Uses Argon2id for secure API key hashing. """ - def __init__(self, root_key: str, viking_fs: VikingFS, encryption_enabled: bool = False): + def __init__( + self, + root_key: str, + viking_fs: VikingFS, + encryption_enabled: bool = False, + ): """Initialize APIKeyManager. Args: @@ -82,6 +89,7 @@ def __init__(self, root_key: str, viking_fs: VikingFS, encryption_enabled: bool async def load(self) -> None: """Load accounts and user keys from VikingFS into memory.""" accounts_data = await self._read_json(ACCOUNTS_PATH) + fresh_workspace = accounts_data is None if accounts_data is None: # First run: create default account now = datetime.now(timezone.utc).isoformat() @@ -92,11 +100,28 @@ async def load(self) -> None: users_path = USERS_PATH_TEMPLATE.format(account_id=account_id) users_data = await self._read_json(users_path) users = users_data.get("users", {}) if users_data else {} + settings_path = SETTINGS_PATH_TEMPLATE.format(account_id=account_id) + settings_data = await self._read_json(settings_path) + namespace_policy, should_persist_settings, inferred_from_legacy = ( + self._resolve_namespace_policy( + settings_data, + allow_legacy_inference=not fresh_workspace, + ) + ) self._accounts[account_id] = AccountInfo( created_at=info.get("created_at", ""), users=users, + namespace_policy=namespace_policy, ) + if should_persist_settings: + await self._save_settings_json(account_id, settings_data=settings_data) + if inferred_from_legacy: + logger.info( + "Inferred namespace policy for legacy account %s using the historical " + "default user-shared/agent-isolated layout", + account_id, + ) for user_id, user_info in users.items(): key_or_hash = user_info.get("key", "") @@ -148,6 +173,28 @@ async def load(self) -> None: sum(len(info.users) for info in self._accounts.values()), ) + def _resolve_namespace_policy( + self, + settings_data: Optional[dict], + *, + allow_legacy_inference: bool, + ) -> tuple[AccountNamespacePolicy, bool, bool]: + """Resolve persisted namespace policy, with one-time inference for legacy accounts.""" + namespace_data = settings_data.get("namespace") if isinstance(settings_data, dict) else None + if isinstance(namespace_data, dict): + return AccountNamespacePolicy.from_dict(namespace_data), False, False + + if allow_legacy_inference: + return self._infer_legacy_namespace_policy(), True, True + return AccountNamespacePolicy(), True, False + + def _infer_legacy_namespace_policy(self) -> AccountNamespacePolicy: + """Map pre-policy accounts to the historical default namespace policy.""" + return AccountNamespacePolicy( + isolate_user_scope_by_agent=False, + isolate_agent_scope_by_user=True, + ) + def resolve(self, api_key: str) -> ResolvedIdentity: """Resolve an API key to identity. Sequential matching: root key first, then user key index.""" if not api_key: @@ -168,6 +215,7 @@ def resolve(self, api_key: str) -> ResolvedIdentity: role=entry.role, account_id=entry.account_id, user_id=entry.user_id, + namespace_policy=self.get_account_policy(entry.account_id), ) else: # Verify plaintext key @@ -176,11 +224,18 @@ def resolve(self, api_key: str) -> ResolvedIdentity: role=entry.role, account_id=entry.account_id, user_id=entry.user_id, + namespace_policy=self.get_account_policy(entry.account_id), ) raise UnauthenticatedError("Invalid API Key") - async def create_account(self, account_id: str, admin_user_id: str) -> str: + async def create_account( + self, + account_id: str, + admin_user_id: str, + *, + namespace_policy: Optional[AccountNamespacePolicy] = None, + ) -> str: """Create a new account (workspace) with its first admin user. Returns the admin user's API key. @@ -190,6 +245,7 @@ async def create_account(self, account_id: str, admin_user_id: str) -> str: now = datetime.now(timezone.utc).isoformat() key = self._generate_api_key() + policy = namespace_policy or AccountNamespacePolicy() if self._encryption_enabled: stored_key = self._hash_api_key(key) @@ -210,6 +266,7 @@ async def create_account(self, account_id: str, admin_user_id: str) -> str: self._accounts[account_id] = AccountInfo( created_at=now, users={admin_user_id: user_info}, + namespace_policy=policy, ) entry = UserKeyEntry( @@ -228,6 +285,7 @@ async def create_account(self, account_id: str, admin_user_id: str) -> str: await self._save_accounts_json() await self._save_users_json(account_id) + await self._save_settings_json(account_id) return key async def delete_account(self, account_id: str) -> None: @@ -435,10 +493,19 @@ def get_accounts(self) -> list: "account_id": account_id, "created_at": info.created_at, "user_count": len(info.users), + **info.namespace_policy.to_dict(), } ) return result + def get_account_policy(self, account_id: Optional[str]) -> AccountNamespacePolicy: + if not account_id: + return AccountNamespacePolicy() + account = self._accounts.get(account_id) + if account is None: + return AccountNamespacePolicy() + return account.namespace_policy + def get_users(self, account_id: str) -> list: """List all users in an account.""" account = self._accounts.get(account_id) @@ -455,6 +522,13 @@ def get_users(self, account_id: str) -> list: ) return result + def has_user(self, account_id: str, user_id: str) -> bool: + """Return True when the account registry contains the given user.""" + account = self._accounts.get(account_id) + if account is None: + return False + return user_id in account.users + # ---- internal helpers ---- def _generate_api_key(self) -> str: @@ -557,3 +631,18 @@ async def _save_users_json(self, account_id: str) -> None: data = {"users": account.users} path = USERS_PATH_TEMPLATE.format(account_id=account_id) await self._write_json(path, data) + + async def _save_settings_json( + self, + account_id: str, + *, + settings_data: Optional[dict] = None, + ) -> None: + """Persist account namespace settings.""" + account = self._accounts.get(account_id) + if account is None: + return + path = SETTINGS_PATH_TEMPLATE.format(account_id=account_id) + merged_settings = dict(settings_data) if isinstance(settings_data, dict) else {} + merged_settings["namespace"] = account.namespace_policy.to_dict() + await self._write_json(path, merged_settings) diff --git a/openviking/server/app.py b/openviking/server/app.py index d173b474c..b85eb672d 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -83,7 +83,8 @@ async def lifespan(app: FastAPI): await api_key_manager.load() app.state.api_key_manager = api_key_manager logger.info( - "APIKeyManager initialized with encryption_enabled=%s", config.encryption_enabled + "APIKeyManager initialized with encryption_enabled=%s", + config.encryption_enabled, ) elif config.auth_mode == "trusted": app.state.api_key_manager = None diff --git a/openviking/server/auth.py b/openviking/server/auth.py index 59d585abe..ac868aed3 100644 --- a/openviking/server/auth.py +++ b/openviking/server/auth.py @@ -116,10 +116,30 @@ async def resolve_identity( raise UnauthenticatedError("Missing API Key when resolving identity.") identity = api_key_manager.resolve(api_key) - identity.agent_id = x_openviking_agent or "default" if identity.role == Role.ROOT: identity.account_id = x_openviking_account or identity.account_id or "default" identity.user_id = x_openviking_user or identity.user_id or "default" + identity.agent_id = x_openviking_agent or identity.agent_id or "default" + return identity + + identity.account_id = identity.account_id or "default" + if x_openviking_account and x_openviking_account != identity.account_id: + raise PermissionDeniedError( + "X-OpenViking-Account cannot override the account for ADMIN/USER API keys." + ) + + if identity.role == Role.ADMIN: + identity.user_id = x_openviking_user or identity.user_id or "default" + identity.agent_id = x_openviking_agent or identity.agent_id or "default" + return identity + + identity.user_id = identity.user_id or "default" + if x_openviking_user and x_openviking_user != identity.user_id: + raise PermissionDeniedError( + "USER API keys cannot override X-OpenViking-User; the effective user is derived " + "from the key." + ) + identity.agent_id = x_openviking_agent or identity.agent_id or "default" return identity @@ -157,6 +177,11 @@ async def get_request_context( identity.agent_id or "default", ), role=identity.role, + namespace_policy=( + api_key_manager.get_account_policy(identity.account_id) + if api_key_manager is not None + else identity.namespace_policy + ), ) request.state.metric_account_id = ctx.account_id set_metric_account_context(account_id=ctx.account_id) diff --git a/openviking/server/identity.py b/openviking/server/identity.py index 0fae4a328..6e35049ca 100644 --- a/openviking/server/identity.py +++ b/openviking/server/identity.py @@ -15,6 +15,29 @@ class Role(str, Enum): USER = "user" +@dataclass(frozen=True) +class AccountNamespacePolicy: + """Account-level namespace isolation policy.""" + + isolate_user_scope_by_agent: bool = False + isolate_agent_scope_by_user: bool = False + + @classmethod + def from_dict(cls, data: Optional[dict]) -> "AccountNamespacePolicy": + if not isinstance(data, dict): + return cls() + return cls( + isolate_user_scope_by_agent=bool(data.get("isolate_user_scope_by_agent", False)), + isolate_agent_scope_by_user=bool(data.get("isolate_agent_scope_by_user", False)), + ) + + def to_dict(self) -> dict: + return { + "isolate_user_scope_by_agent": self.isolate_user_scope_by_agent, + "isolate_agent_scope_by_user": self.isolate_agent_scope_by_user, + } + + @dataclass class ResolvedIdentity: """Output of auth middleware: raw identity resolved from API Key.""" @@ -23,6 +46,7 @@ class ResolvedIdentity: account_id: Optional[str] = None user_id: Optional[str] = None agent_id: Optional[str] = None + namespace_policy: AccountNamespacePolicy = field(default_factory=AccountNamespacePolicy) @dataclass @@ -31,6 +55,7 @@ class RequestContext: user: UserIdentifier role: Role + namespace_policy: AccountNamespacePolicy = field(default_factory=AccountNamespacePolicy) @property def account_id(self) -> str: diff --git a/openviking/server/routers/admin.py b/openviking/server/routers/admin.py index 9e0e5682d..8f7fe3456 100644 --- a/openviking/server/routers/admin.py +++ b/openviking/server/routers/admin.py @@ -7,7 +7,7 @@ from openviking.server.auth import get_request_context from openviking.server.dependencies import get_service -from openviking.server.identity import RequestContext, Role +from openviking.server.identity import AccountNamespacePolicy, RequestContext, Role from openviking.server.models import Response from openviking.storage.viking_fs import get_viking_fs from openviking_cli.exceptions import PermissionDeniedError @@ -33,6 +33,8 @@ class CreateAccountRequest(BaseModel): account_id: str admin_user_id: str + isolate_user_scope_by_agent: bool = False + isolate_agent_scope_by_user: bool = False class RegisterUserRequest(BaseModel): @@ -94,11 +96,20 @@ async def create_account( ): """Create a new account (workspace) with its first admin user.""" manager = _get_api_key_manager(request) - user_key = await manager.create_account(body.account_id, body.admin_user_id) + policy = AccountNamespacePolicy( + isolate_user_scope_by_agent=body.isolate_user_scope_by_agent, + isolate_agent_scope_by_user=body.isolate_agent_scope_by_user, + ) + user_key = await manager.create_account( + body.account_id, + body.admin_user_id, + namespace_policy=policy, + ) service = get_service() account_ctx = RequestContext( user=UserIdentifier(body.account_id, body.admin_user_id, "default"), role=Role.ADMIN, + namespace_policy=policy, ) await service.initialize_account_directories(account_ctx) await service.initialize_user_directories(account_ctx) @@ -108,6 +119,7 @@ async def create_account( "account_id": body.account_id, "admin_user_id": body.admin_user_id, "user_key": user_key, + **policy.to_dict(), }, ) @@ -184,6 +196,7 @@ async def register_user( user_ctx = RequestContext( user=UserIdentifier(account_id, body.user_id, "default"), role=Role.USER, + namespace_policy=manager.get_account_policy(account_id), ) await service.initialize_user_directories(user_ctx) return Response( diff --git a/openviking/server/routers/sessions.py b/openviking/server/routers/sessions.py index 919459a69..0c7cecc63 100644 --- a/openviking/server/routers/sessions.py +++ b/openviking/server/routers/sessions.py @@ -3,20 +3,22 @@ """Sessions endpoints for OpenViking HTTP Server.""" import logging -from datetime import datetime +import re from typing import Any, Dict, List, Literal, Optional -from fastapi import APIRouter, Depends, Path, Query +from fastapi import APIRouter, Depends, Path, Query, Request from pydantic import BaseModel, model_validator from openviking.message.part import TextPart, part_from_dict from openviking.server.auth import get_request_context from openviking.server.dependencies import get_service -from openviking.server.identity import RequestContext +from openviking.server.identity import RequestContext, Role from openviking.server.models import ErrorInfo, Response +from openviking_cli.exceptions import InvalidArgumentError router = APIRouter(prefix="/api/v1/sessions", tags=["sessions"]) logger = logging.getLogger(__name__) +_ROLE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") class TextPartRequest(BaseModel): @@ -62,6 +64,7 @@ class AddMessageRequest(BaseModel): """ role: str + role_id: Optional[str] = None content: Optional[str] = None parts: Optional[List[Dict[str, Any]]] = None created_at: Optional[str] = None @@ -98,6 +101,47 @@ def _to_jsonable(value: Any) -> Any: return value +def _request_auth_mode(request: Request) -> str: + config = getattr(request.app.state, "config", None) + return getattr(config, "auth_mode", "api_key") + + +def _resolve_message_role_id( + http_request: Request, + request: AddMessageRequest, + ctx: RequestContext, +) -> Optional[str]: + if request.role not in {"user", "assistant"}: + return request.role_id + + role_id_provided = "role_id" in request.model_fields_set + allow_explicit_role_id = _request_auth_mode(http_request) == "trusted" or ctx.role in { + Role.ROOT, + Role.ADMIN, + } + if not allow_explicit_role_id and role_id_provided: + raise InvalidArgumentError( + "USER requests cannot explicitly set role_id; it is derived from the request context." + ) + + role_id = request.role_id if allow_explicit_role_id else None + if not role_id: + role_id = ctx.user.user_id if request.role == "user" else ctx.user.agent_id + + if not _ROLE_ID_PATTERN.match(role_id): + raise InvalidArgumentError("role_id must be alpha-numeric string.") + + if request.role == "user": + api_key_manager = getattr(http_request.app.state, "api_key_manager", None) + has_user = getattr(api_key_manager, "has_user", None) + if callable(has_user) and not has_user(ctx.account_id, role_id): + raise InvalidArgumentError( + f"role_id '{role_id}' is not a registered user in account '{ctx.account_id}'." + ) + + return role_id + + @router.post("") async def create_session( request: Optional[CreateSessionRequest] = None, @@ -233,6 +277,7 @@ async def extract_session( @router.post("/{session_id}/messages") async def add_message( request: AddMessageRequest, + http_request: Request, session_id: str = Path(..., description="Session ID"), _ctx: RequestContext = Depends(get_request_context), ): @@ -253,6 +298,7 @@ async def add_message( """ service = get_service() session = await service.sessions.get(session_id, _ctx, auto_create=True) + role_id = _resolve_message_role_id(http_request, request, _ctx) if request.parts is not None: parts = [part_from_dict(p) for p in request.parts] @@ -260,7 +306,12 @@ async def add_message( parts = [TextPart(text=request.content or "")] # created_at 直接传递给 session (ISO string) - session.add_message(request.role, parts, created_at=request.created_at) + session.add_message( + request.role, + parts, + role_id=role_id, + created_at=request.created_at, + ) return Response( status="ok", result={ diff --git a/openviking/service/core.py b/openviking/service/core.py index 771cdb609..ae9851ab2 100644 --- a/openviking/service/core.py +++ b/openviking/service/core.py @@ -282,7 +282,10 @@ async def initialize(self) -> None: logger.info("QueueManager workers started") # Initialize directories - directory_initializer = DirectoryInitializer(vikingdb=self._vikingdb_manager) + directory_initializer = DirectoryInitializer( + vikingdb=self._vikingdb_manager, + viking_fs=self._viking_fs, + ) self._directory_initializer = directory_initializer default_ctx = RequestContext(user=self._user, role=Role.ROOT) account_count = await directory_initializer.initialize_account_directories(default_ctx) diff --git a/openviking/service/session_service.py b/openviking/service/session_service.py index e81be1b35..e5772714b 100644 --- a/openviking/service/session_service.py +++ b/openviking/service/session_service.py @@ -8,7 +8,8 @@ from typing import Any, Dict, List, Optional -from openviking.server.identity import RequestContext +from openviking.core.namespace import canonical_session_uri +from openviking.server.identity import RequestContext, Role from openviking.service.task_tracker import get_task_tracker from openviking.session import Session from openviking.session.compressor import SessionCompressor @@ -149,7 +150,7 @@ async def sessions(self, ctx: RequestContext) -> List[Dict[str, Any]]: List of session info dicts """ self._ensure_initialized() - session_base_uri = f"viking://session/{ctx.user.user_space_name()}" + session_base_uri = canonical_session_uri() try: entries = await self._viking_fs.ls(session_base_uri, ctx=ctx) @@ -179,7 +180,12 @@ async def delete(self, session_id: str, ctx: RequestContext) -> bool: True if deleted successfully """ self._ensure_initialized() - session_uri = f"viking://session/{ctx.user.user_space_name()}/{session_id}" + if ctx.role not in {Role.ADMIN, Role.ROOT}: + from openviking_cli.exceptions import PermissionDeniedError + + raise PermissionDeniedError("Deleting shared sessions requires ADMIN or ROOT role") + + session_uri = canonical_session_uri(session_id) try: await self._viking_fs.rm(session_uri, recursive=True, ctx=ctx) @@ -248,7 +254,6 @@ async def get_commit_task(self, task_id: str, ctx: RequestContext) -> Optional[D task = get_task_tracker().get( task_id, owner_account_id=ctx.account_id, - owner_user_id=ctx.user.user_id, ) return task.to_dict() if task else None diff --git a/openviking/session/compressor_v2.py b/openviking/session/compressor_v2.py index 2d5ce523f..0afda749d 100644 --- a/openviking/session/compressor_v2.py +++ b/openviking/session/compressor_v2.py @@ -11,16 +11,16 @@ from typing import List, Optional from openviking.core.context import Context +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.message import Message from openviking.server.identity import RequestContext from openviking.session.memory import ExtractLoop, MemoryUpdater from openviking.session.memory.utils.json_parser import JsonUtils from openviking.storage import VikingDBManager from openviking.storage.viking_fs import get_viking_fs -from openviking.telemetry import get_current_telemetry +from openviking.telemetry import get_current_telemetry, tracer from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils import get_logger -from openviking.telemetry import tracer from openviking_cli.utils.config import get_openviking_config logger = get_logger(__name__) @@ -150,8 +150,8 @@ async def extract_long_term_memories( for schema in schemas: if not schema.directory: continue - user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" - agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" + user_space = user_space_fragment(ctx) if ctx and ctx.user else "default" + agent_space = agent_space_fragment(ctx) if ctx and ctx.user else "default" # 使用 Jinja2 渲染 directory import jinja2 @@ -225,7 +225,9 @@ async def extract_long_term_memories( extract_context = ExtractContext(messages) # Apply operations - result = await updater.apply_operations(operations, ctx, extract_context=extract_context) + result = await updater.apply_operations( + operations, ctx, extract_context=extract_context + ) tracer.info( f"Applied memory operations: written={len(result.written_uris)}, " diff --git a/openviking/session/memory/extract_loop.py b/openviking/session/memory/extract_loop.py index 191a19340..ffd521e6c 100644 --- a/openviking/session/memory/extract_loop.py +++ b/openviking/session/memory/extract_loop.py @@ -10,6 +10,7 @@ import json from typing import Any, Dict, List, Optional, Set, Tuple +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.models.vlm.base import VLMBase from openviking.server.identity import RequestContext from openviking.session.memory.schema_model_generator import ( @@ -330,8 +331,8 @@ def _validate_operations(self, operations: Any) -> None: operations, schemas, registry, - user_space=self.ctx.user.user_space_name(), - agent_space=self.ctx.user.agent_space_name(), + user_space=user_space_fragment(self.ctx), + agent_space=agent_space_fragment(self.ctx), extract_context=self._extract_context, ) if not is_valid: @@ -502,8 +503,8 @@ async def _check_unread_existing_files( uri = resolve_flat_model_uri( item_dict, registry, - user_space=self.ctx.user.user_space_name(), - agent_space=self.ctx.user.agent_space_name(), + user_space=user_space_fragment(self.ctx), + agent_space=agent_space_fragment(self.ctx), memory_type=field_name, extract_context=self._extract_context, ) diff --git a/openviking/session/memory/memory_type_registry.py b/openviking/session/memory/memory_type_registry.py index dca13ca68..b3a6aa069 100644 --- a/openviking/session/memory/memory_type_registry.py +++ b/openviking/session/memory/memory_type_registry.py @@ -9,6 +9,7 @@ import yaml +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.session.memory.dataclass import MemoryField, MemoryTypeSchema from openviking.session.memory.merge_op import MergeOp from openviking.session.memory.merge_op.base import FieldType @@ -205,8 +206,8 @@ async def initialize_memory_files(self, ctx: Any) -> None: logger = get_logger(__name__) - user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" - agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" + user_space = user_space_fragment(ctx) if ctx and ctx.user else "default" + agent_space = agent_space_fragment(ctx) if ctx and ctx.user else "default" logger.info( f"[MemoryTypeRegistry] Starting memory files initialization for user={user_space}, agent={agent_space}" diff --git a/openviking/session/memory/memory_updater.py b/openviking/session/memory/memory_updater.py index cad4869d9..631b41202 100644 --- a/openviking/session/memory/memory_updater.py +++ b/openviking/session/memory/memory_updater.py @@ -10,11 +10,13 @@ from typing import Any, Dict, List, Optional, Tuple import jinja2 + +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.message import Message from openviking.server.identity import RequestContext from openviking.session.memory.dataclass import MemoryField from openviking.session.memory.memory_type_registry import MemoryTypeRegistry -from openviking.session.memory.merge_op import MergeOpFactory, PatchOp +from openviking.session.memory.merge_op import MergeOpFactory from openviking.session.memory.utils import ( deserialize_full, flat_model_to_dict, @@ -270,8 +272,8 @@ async def apply_operations( raise ValueError("MemoryTypeRegistry is required for URI resolution") # Get actual user/agent space from ctx - user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" - agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" + user_space = user_space_fragment(ctx) if ctx and ctx.user else "default" + agent_space = agent_space_fragment(ctx) if ctx and ctx.user else "default" # Resolve all URIs first (pass extract_context for template rendering) resolved_ops = resolve_all_operations( @@ -339,8 +341,8 @@ async def apply_operations( if schema.overview_template and schema.directory: env = jinja2.Environment(autoescape=False) base_dir = env.from_string(schema.directory).render( - user_space=ctx.user.user_space_name(), - agent_space=ctx.user.agent_space_name(), + user_space=user_space, + agent_space=agent_space, ) # Check if this uri belongs to this memory type's directory if dir_path.startswith(base_dir.rstrip("/")): @@ -350,7 +352,9 @@ async def apply_operations( # Generate overview for each unique directory for directory, memory_type in dir_to_memory_type.items(): - logger.info(f"[apply_operations] Generating overview for {memory_type} at {directory}") + logger.info( + f"[apply_operations] Generating overview for {memory_type} at {directory}" + ) await self.generate_overview(memory_type, directory, ctx, extract_context) return result @@ -566,13 +570,17 @@ async def generate_overview( """ from openviking.session.memory.utils.messages import parse_memory_file_with_fields - tracer.info(f"[generate_overview] Called with memory_type={memory_type}, directory={directory}") + tracer.info( + f"[generate_overview] Called with memory_type={memory_type}, directory={directory}" + ) # Get the schema for this memory type registry = self._registry tracer.info(f"[generate_overview] registry={registry}") schema = registry.get(memory_type) - tracer.info(f"[generate_overview] schema={schema}, overview_template={schema.overview_template if schema else None}") + tracer.info( + f"[generate_overview] schema={schema}, overview_template={schema.overview_template if schema else None}" + ) if not schema or not schema.overview_template: logger.debug(f"No overview_template for memory type: {memory_type}") return @@ -590,7 +598,11 @@ async def generate_overview( base_uri = directory.rstrip("/") for entry in entries: name = entry.get("name", "") - if name.endswith(".md") and not name.endswith(".overview.md") and not name.endswith(".abstract.md"): + if ( + name.endswith(".md") + and not name.endswith(".overview.md") + and not name.endswith(".abstract.md") + ): md_files.append(f"{base_uri}/{name}") tracer.info(f"[generate_overview] Filtered md_files: {md_files}") @@ -620,15 +632,19 @@ async def generate_overview( try: content = await viking_fs.read_file(file_path, ctx=ctx) parsed = parse_memory_file_with_fields(content) - tracer.info(f"[generate_overview] Parsed {file_path}: {parsed.keys() if parsed else None}") + tracer.info( + f"[generate_overview] Parsed {file_path}: {parsed.keys() if parsed else None}" + ) # Extract filename from path filename = file_path.split("/")[-1] - items.append({ - "file_name": filename, - "file_content": parsed, - }) + items.append( + { + "file_name": filename, + "file_content": parsed, + } + ) except Exception as e: logger.warning(f"Failed to parse {file_path}: {e}") continue diff --git a/openviking/session/memory/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index f5a97c6c7..f0ab659ee 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -8,8 +8,9 @@ import json import os -from typing import Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.server.identity import RequestContext from openviking.session.memory.core import ExtractContextProvider from openviking.session.memory.memory_type_registry import MemoryTypeRegistry @@ -18,12 +19,14 @@ get_tool, ) from openviking.storage.viking_fs import VikingFS -from openviking.telemetry import tracer from openviking_cli.utils import get_logger from openviking_cli.utils.config import get_openviking_config logger = get_logger(__name__) +if TYPE_CHECKING: + from openviking.session.memory.memory_updater import ExtractContext + class SessionExtractContextProvider(ExtractContextProvider): """会话提取 Provider - 从会话消息中提取记忆""" @@ -96,7 +99,6 @@ def instruction(self) -> str: See GenericOverviewEdit in the JSON Schema below. """ - def _build_conversation_message(self) -> Dict[str, Any]: """构建包含 Conversation History 的 user message""" from datetime import datetime @@ -233,8 +235,8 @@ async def prefetch( continue # Replace variables in directory path with actual user/agent space - user_space = ctx.user.user_space_name() if ctx and ctx.user else "default" - agent_space = ctx.user.agent_space_name() if ctx and ctx.user else "default" + user_space = user_space_fragment(ctx) if ctx and ctx.user else "default" + agent_space = agent_space_fragment(ctx) if ctx and ctx.user else "default" import jinja2 env = jinja2.Environment(autoescape=False) diff --git a/openviking/session/memory_deduplicator.py b/openviking/session/memory_deduplicator.py index c57ae8216..22369696c 100644 --- a/openviking/session/memory_deduplicator.py +++ b/openviking/session/memory_deduplicator.py @@ -15,6 +15,7 @@ from typing import Dict, List, Optional from openviking.core.context import Context +from openviking.core.namespace import canonical_agent_root, canonical_user_root from openviking.models.embedder.base import EmbedResult, embed_compat from openviking.prompts import render_prompt from openviking.server.identity import RequestContext @@ -74,12 +75,12 @@ class MemoryDeduplicator: _AGENT_CATEGORIES = {"cases", "patterns", "tools", "skills"} @staticmethod - def _category_uri_prefix(category: str, user) -> str: + def _category_uri_prefix(category: str, ctx: RequestContext) -> str: """Build category URI prefix with space segment.""" if category in MemoryDeduplicator._USER_CATEGORIES: - return f"viking://user/{user.user_space_name()}/memories/{category}/" + return f"{canonical_user_root(ctx)}/memories/{category}/" elif category in MemoryDeduplicator._AGENT_CATEGORIES: - return f"viking://agent/{user.agent_space_name()}/memories/{category}/" + return f"{canonical_agent_root(ctx)}/memories/{category}/" return "" def __init__( @@ -154,27 +155,18 @@ async def _find_similar_memories( embed_result: EmbedResult = await embed_compat(self.embedder, query_text, is_query=True) query_vector = embed_result.dense_vector - category_uri_prefix = self._category_uri_prefix(candidate.category.value, candidate.user) - - owner = candidate.user - owner_space = None - if owner and hasattr(owner, "user_space_name"): - owner_space = ( - owner.agent_space_name() - if candidate.category.value in {"cases", "patterns"} - else owner.user_space_name() - ) + category_uri_prefix = self._category_uri_prefix(candidate.category.value, ctx) logger.debug( "Dedup prefilter candidate category=%s owner_space=%s uri_prefix=%s", candidate.category.value, - owner_space, + None, category_uri_prefix, ) try: # Search with memory-scope filter. results = await self.vikingdb.search_similar_memories( - owner_space=owner_space, + owner_space=None, category_uri_prefix=category_uri_prefix, query_vector=query_vector, limit=5, diff --git a/openviking/session/memory_extractor.py b/openviking/session/memory_extractor.py index fefef0ba7..1ea96434a 100644 --- a/openviking/session/memory_extractor.py +++ b/openviking/session/memory_extractor.py @@ -15,6 +15,7 @@ from uuid import uuid4 from openviking.core.context import Context, ContextType, Vectorize +from openviking.core.namespace import canonical_agent_root, canonical_user_root from openviking.prompts import render_prompt from openviking.server.identity import RequestContext from openviking.storage.viking_fs import get_viking_fs @@ -141,8 +142,8 @@ def _get_owner_space(category: MemoryCategory, ctx: RequestContext) -> str: CASES / PATTERNS → agent_space """ if category in MemoryExtractor._USER_CATEGORIES: - return ctx.user.user_space_name() - return ctx.user.agent_space_name() + return ctx.user.user_id + return ctx.user.agent_id @staticmethod def _detect_output_language(messages: List, fallback_language: str = "en") -> str: @@ -459,11 +460,11 @@ async def create_memory( payload = await self._append_to_profile(candidate, viking_fs, ctx=ctx) if not payload: return None - user_space = ctx.user.user_space_name() - memory_uri = f"viking://user/{user_space}/memories/profile.md" + user_root = canonical_user_root(ctx) + memory_uri = f"{user_root}/memories/profile.md" memory = Context( uri=memory_uri, - parent_uri=f"viking://user/{user_space}/memories", + parent_uri=f"{user_root}/memories", is_leaf=True, abstract=payload.abstract, context_type=ContextType.MEMORY.value, @@ -484,9 +485,9 @@ async def create_memory( MemoryCategory.ENTITIES, MemoryCategory.EVENTS, ]: - parent_uri = f"viking://user/{ctx.user.user_space_name()}/{cat_dir}" + parent_uri = f"{canonical_user_root(ctx)}/{cat_dir}" else: # CASES, PATTERNS - parent_uri = f"viking://agent/{ctx.user.agent_space_name()}/{cat_dir}" + parent_uri = f"{canonical_agent_root(ctx)}/{cat_dir}" # Generate file URI (store directly as .md file, no directory creation) memory_id = f"mem_{str(uuid4())}" @@ -524,7 +525,7 @@ async def _append_to_profile( ctx: RequestContext, ) -> Optional[MergedMemoryPayload]: """Update user profile - always merge with existing content.""" - uri = f"viking://user/{ctx.user.user_space_name()}/memories/profile.md" + uri = f"{canonical_user_root(ctx)}/memories/profile.md" existing = "" try: existing = await viking_fs.read_file(uri, ctx=ctx) or "" @@ -632,8 +633,8 @@ async def _merge_tool_memory( logger.warning("Tool name is empty, skipping tool memory merge") return None - agent_space = ctx.user.agent_space_name() - uri = f"viking://agent/{agent_space}/memories/tools/{tool_name}.md" + agent_root = canonical_agent_root(ctx) + uri = f"{agent_root}/memories/tools/{tool_name}.md" viking_fs = get_viking_fs() if not viking_fs: @@ -1144,10 +1145,10 @@ def _create_tool_context( abstract_override: Optional[str] = None, ) -> Context: """创建 Tool Memory 的 Context 对象""" - agent_space = ctx.user.agent_space_name() + agent_root = canonical_agent_root(ctx) return Context( uri=uri, - parent_uri=f"viking://agent/{agent_space}/memories/tools", + parent_uri=f"{agent_root}/memories/tools", is_leaf=True, abstract=abstract_override or candidate.abstract, context_type=ContextType.MEMORY.value, @@ -1155,7 +1156,7 @@ def _create_tool_context( session_id=candidate.source_session, user=candidate.user, account_id=ctx.account_id, - owner_space=agent_space, + owner_space=ctx.user.agent_id, ) def _extract_tool_guidelines(self, content: str) -> str: @@ -1186,8 +1187,8 @@ async def _merge_skill_memory( logger.warning("Skill name is empty, skipping skill memory merge") return None - agent_space = ctx.user.agent_space_name() - uri = f"viking://agent/{agent_space}/memories/skills/{skill_name}.md" + agent_root = canonical_agent_root(ctx) + uri = f"{agent_root}/memories/skills/{skill_name}.md" viking_fs = get_viking_fs() if not viking_fs: @@ -1472,10 +1473,10 @@ def _create_skill_context( abstract_override: Optional[str] = None, ) -> Context: """创建 Skill Memory 的 Context 对象""" - agent_space = ctx.user.agent_space_name() + agent_root = canonical_agent_root(ctx) return Context( uri=uri, - parent_uri=f"viking://agent/{agent_space}/memories/skills", + parent_uri=f"{agent_root}/memories/skills", is_leaf=True, abstract=abstract_override or candidate.abstract, context_type=ContextType.MEMORY.value, @@ -1483,7 +1484,7 @@ def _create_skill_context( session_id=candidate.source_session, user=candidate.user, account_id=ctx.account_id, - owner_space=agent_space, + owner_space=ctx.user.agent_id, ) def _extract_skill_guidelines(self, content: str) -> str: diff --git a/openviking/session/session.py b/openviking/session/session.py index b6ec14965..a9da3d441 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional from uuid import uuid4 +from openviking.core.namespace import canonical_session_uri from openviking.message import Message, Part from openviking.server.identity import RequestContext, Role from openviking.telemetry import get_current_telemetry, tracer @@ -60,6 +61,9 @@ class SessionMeta: session_id: str = "" created_at: str = "" updated_at: str = "" + created_by_user_id: str = "" + participant_user_ids: List[str] = field(default_factory=list) + participant_agent_ids: List[str] = field(default_factory=list) message_count: int = 0 commit_count: int = 0 memories_extracted: Dict[str, int] = field( @@ -94,6 +98,9 @@ def to_dict(self) -> Dict[str, Any]: "session_id": self.session_id, "created_at": self.created_at, "updated_at": self.updated_at, + "created_by_user_id": self.created_by_user_id, + "participant_user_ids": list(self.participant_user_ids), + "participant_agent_ids": list(self.participant_agent_ids), "message_count": self.message_count, "commit_count": self.commit_count, "memories_extracted": dict(self.memories_extracted), @@ -112,6 +119,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta": session_id=data.get("session_id", ""), created_at=data.get("created_at", ""), updated_at=data.get("updated_at", ""), + created_by_user_id=data.get("created_by_user_id", ""), + participant_user_ids=list(data.get("participant_user_ids", [])), + participant_agent_ids=list(data.get("participant_agent_ids", [])), message_count=data.get("message_count", 0), commit_count=data.get("commit_count", 0), memories_extracted={ @@ -171,13 +181,18 @@ def __init__( self.session_id = session_id or str(uuid4()) self.created_at = int(datetime.now(timezone.utc).timestamp() * 1000) self._auto_commit_threshold = auto_commit_threshold - self._session_uri = f"viking://session/{self.user.user_space_name()}/{self.session_id}" + self._session_uri = canonical_session_uri(self.session_id) self._messages: List[Message] = [] self._usage_records: List[Usage] = [] self._compression: SessionCompression = SessionCompression() self._stats: SessionStats = SessionStats() - self._meta = SessionMeta(session_id=self.session_id, created_at=get_current_timestamp()) + self._meta = SessionMeta( + session_id=self.session_id, + created_at=get_current_timestamp(), + created_by_user_id=self.ctx.user.user_id, + participant_user_ids=[self.ctx.user.user_id], + ) self._loaded = False logger.info(f"Session created: {self.session_id} for user {self.user}") @@ -225,6 +240,13 @@ async def load(self): self._meta.message_count = len(self._messages) self._meta.commit_count = self._compression.compression_index + if not self._meta.created_by_user_id: + self._meta.created_by_user_id = self.ctx.user.user_id + if not self._meta.participant_user_ids: + self._meta.participant_user_ids = [self._meta.created_by_user_id] + for message in self._messages: + self._record_participant(message) + self._loaded = True async def exists(self) -> bool: @@ -315,6 +337,7 @@ def add_message( self, role: str, parts: List[Part], + role_id: Optional[str] = None, created_at: str = None, ) -> Message: """Add a message.""" @@ -322,9 +345,11 @@ def add_message( id=f"msg_{uuid4().hex}", role=role, parts=parts, + role_id=role_id, created_at=created_at or datetime.now(timezone.utc).isoformat(), ) self._messages.append(msg) + self._record_participant(msg) # Update statistics if role == "user": @@ -337,6 +362,14 @@ def add_message( self._save_meta_sync() return msg + def _record_participant(self, msg: Message) -> None: + if msg.role == "user" and msg.role_id: + if msg.role_id not in self._meta.participant_user_ids: + self._meta.participant_user_ids.append(msg.role_id) + if msg.role == "assistant" and msg.role_id: + if msg.role_id not in self._meta.participant_agent_ids: + self._meta.participant_agent_ids.append(msg.role_id) + def update_tool_part( self, message_id: str, diff --git a/openviking/storage/collection_schemas.py b/openviking/storage/collection_schemas.py index 030cd3f8b..f4d5a9d43 100644 --- a/openviking/storage/collection_schemas.py +++ b/openviking/storage/collection_schemas.py @@ -21,7 +21,6 @@ from openviking.storage.errors import ( CollectionNotFoundError, EmbeddingConfigurationError, - EmbeddingRebuildRequiredError, ) from openviking.storage.queuefs.embedding_msg import EmbeddingMsg from openviking.storage.queuefs.named_queue import DequeueHandlerBase @@ -103,7 +102,8 @@ def context_collection( {"FieldName": "tags", "FieldType": "string"}, {"FieldName": "abstract", "FieldType": "string"}, {"FieldName": "account_id", "FieldType": "string"}, - {"FieldName": "owner_space", "FieldType": "string"}, + {"FieldName": "owner_user_id", "FieldType": "string"}, + {"FieldName": "owner_agent_id", "FieldType": "string"}, ] ) scalar_index = [ @@ -120,7 +120,8 @@ def context_collection( "name", "tags", "account_id", - "owner_space", + "owner_user_id", + "owner_agent_id", ] ) return { @@ -419,7 +420,7 @@ async def on_dequeue(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, self._breaker_open_suppressed_count = 0 except CircuitBreakerOpen: self._log_breaker_open_reenqueue_summary() - if self._vikingdb.has_queue_manager: + if getattr(self._vikingdb, "has_queue_manager", False): wait = self._circuit_breaker.retry_after if wait > 0: await asyncio.sleep(wait) @@ -489,7 +490,7 @@ async def on_dequeue(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, # Transient or unknown — re-enqueue for retry logger.warning(error_msg) self._circuit_breaker.record_failure(embed_err) - if self._vikingdb.has_queue_manager: + if getattr(self._vikingdb, "has_queue_manager", False): try: await self._vikingdb.enqueue_embedding_msg(embedding_msg) self._merge_request_stats( diff --git a/openviking/storage/queuefs/embedding_msg_converter.py b/openviking/storage/queuefs/embedding_msg_converter.py index cef13360c..6ea825ac4 100644 --- a/openviking/storage/queuefs/embedding_msg_converter.py +++ b/openviking/storage/queuefs/embedding_msg_converter.py @@ -8,6 +8,7 @@ """ from openviking.core.context import Context, ContextLevel +from openviking.core.namespace import owner_fields_for_uri from openviking.storage.queuefs.embedding_msg import EmbeddingMsg from openviking.telemetry import get_current_telemetry from openviking_cli.utils import get_logger @@ -33,21 +34,14 @@ def from_context(context: Context) -> EmbeddingMsg: if not context_data.get("account_id"): user = context_data.get("user") or {} context_data["account_id"] = user.get("account_id", "default") - if not context_data.get("owner_space"): - user = context_data.get("user") or {} - uri = context_data.get("uri", "") - account = user.get("account_id", "default") - user_id = user.get("user_id", "default") - agent_id = user.get("agent_id", "default") - from openviking_cli.session.user_id import UserIdentifier - - owner_user = UserIdentifier(account, user_id, agent_id) - if uri.startswith("viking://agent/"): - context_data["owner_space"] = owner_user.agent_space_name() - elif uri.startswith("viking://user/") or uri.startswith("viking://session/"): - context_data["owner_space"] = owner_user.user_space_name() - else: - context_data["owner_space"] = "" + if context_data.get("owner_user_id") is None and context_data.get("owner_agent_id") is None: + owner_fields = owner_fields_for_uri( + context_data.get("uri", ""), + user=context.user, + account_id=context_data.get("account_id"), + ) + context_data["owner_user_id"] = owner_fields["owner_user_id"] + context_data["owner_agent_id"] = owner_fields["owner_agent_id"] # Derive level field for hierarchical retrieval. uri = context_data.get("uri", "") diff --git a/openviking/storage/queuefs/semantic_processor.py b/openviking/storage/queuefs/semantic_processor.py index dc1437800..3f4217a3c 100644 --- a/openviking/storage/queuefs/semantic_processor.py +++ b/openviking/storage/queuefs/semantic_processor.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Set, Tuple +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.metrics.account_context import ( bind_metric_account_context, reset_metric_account_context, @@ -161,9 +162,9 @@ def _owner_space_for_uri(uri: str, ctx: RequestContext) -> str: caller's space name. """ if uri.startswith("viking://agent/"): - return ctx.user.agent_space_name() + return agent_space_fragment(ctx) if uri.startswith("viking://user/") or uri.startswith("viking://session/"): - return ctx.user.user_space_name() + return user_space_fragment(ctx) # resources and anything else → shared (empty owner_space) return "" diff --git a/openviking/storage/viking_fs.py b/openviking/storage/viking_fs.py index 6c99752dc..f5ac1ef1a 100644 --- a/openviking/storage/viking_fs.py +++ b/openviking/storage/viking_fs.py @@ -23,13 +23,25 @@ from pathlib import PurePath from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from openviking.core.namespace import ( + NamespaceShapeError, + canonicalize_uri, +) +from openviking.core.namespace import ( + is_accessible as namespace_is_accessible, +) from openviking.pyagfs.exceptions import AGFSClientError, AGFSDirectoryNotEmptyError, AGFSHTTPError from openviking.resource.watch_storage import is_watch_task_control_uri from openviking.server.error_mapping import is_not_found_error, map_exception from openviking.server.identity import RequestContext, Role from openviking.telemetry import get_current_telemetry from openviking.utils.time_utils import format_simplified, get_current_timestamp, parse_iso_datetime -from openviking_cli.exceptions import FailedPreconditionError, NotFoundError +from openviking_cli.exceptions import ( + FailedPreconditionError, + InvalidArgumentError, + NotFoundError, + PermissionDeniedError, +) from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils.logger import get_logger from openviking_cli.utils.uri import VikingURI @@ -276,14 +288,19 @@ def _normalized_uri_parts(cls, uri: str) -> tuple[str, List[str]]: for part in parts: if part in {".", ".."}: - raise PermissionError(f"Unsafe URI traversal segment '{part}' in {normalized}") + raise PermissionDeniedError( + f"Unsafe URI traversal segment '{part}' in {normalized}", + resource=normalized, + ) if "\\" in part: - raise PermissionError( - f"Unsafe URI path separator '\\\\' in component '{part}' of {normalized}" + raise PermissionDeniedError( + f"Unsafe URI path separator '\\\\' in component '{part}' of {normalized}", + resource=normalized, ) if len(part) >= 2 and part[1] == ":" and part[0].isalpha(): - raise PermissionError( - f"Unsafe URI drive-prefixed component '{part}' in {normalized}" + raise PermissionDeniedError( + f"Unsafe URI drive-prefixed component '{part}' in {normalized}", + resource=normalized, ) return normalized, parts @@ -292,14 +309,17 @@ def _ensure_access(self, uri: str, ctx: Optional[RequestContext]) -> None: real_ctx = self._ctx_or_default(ctx) normalized_uri, _ = self._normalized_uri_parts(uri) if not self._is_accessible(normalized_uri, real_ctx): - raise PermissionError(f"Access denied for {uri}") + raise PermissionDeniedError(f"Access denied for {uri}", resource=normalized_uri) def _ensure_mutable_access(self, uri: str, ctx: Optional[RequestContext]) -> None: self._ensure_access(uri, ctx) real_ctx = self._ctx_or_default(ctx) normalized_uri, _ = self._normalized_uri_parts(uri) if real_ctx.role != Role.ROOT and normalized_uri.rstrip("/") == "viking://temp": - raise PermissionError("Temp root is read-only for non-root users") + raise PermissionDeniedError( + "Temp root is read-only for non-root users", + resource=normalized_uri, + ) # ========== AGFS Basic Commands ========== @@ -1004,6 +1024,10 @@ async def find( ) if target_uri and target_uri not in {"/", "viking://"}: + try: + target_uri = canonicalize_uri(target_uri, self._ctx_or_default(ctx)) + except NamespaceShapeError as exc: + raise InvalidArgumentError(str(exc)) from exc self._ensure_access(target_uri, ctx) storage = self._get_vector_store() @@ -1095,6 +1119,16 @@ async def search( # Normalize target_uri to list target_uri_list = [target_uri] if isinstance(target_uri, str) else (target_uri or []) + real_ctx = self._ctx_or_default(ctx) + canonical_target_uri_list: List[str] = [] + for item in target_uri_list: + if not item or item in {"/", "viking://"}: + continue + try: + canonical_target_uri_list.append(canonicalize_uri(item, real_ctx)) + except NamespaceShapeError as exc: + raise InvalidArgumentError(str(exc)) from exc + target_uri_list = canonical_target_uri_list # Use first URI for context inference and access check primary_target_uri = target_uri_list[0] if target_uri_list else "" @@ -1306,7 +1340,8 @@ def _uri_to_path(self, uri: str, ctx: Optional[RequestContext] = None) -> str: """ real_ctx = self._ctx_or_default(ctx) account_id = real_ctx.account_id - _, parts = self._normalized_uri_parts(uri) + canonical_uri = canonicalize_uri(uri, real_ctx) + _, parts = self._normalized_uri_parts(canonical_uri) if not parts: return f"/local/{account_id}" @@ -1410,16 +1445,7 @@ def _is_accessible(self, uri: str, ctx: RequestContext) -> bool: return self._is_legacy_temp_uri_parts(parts) if scope == "_system": return False - - space = self._extract_space_from_uri(normalized_uri) - if space is None: - return True - - if scope in {"user", "session"}: - return space == ctx.user.user_space_name() - if scope == "agent": - return space == ctx.user.agent_space_name() - return True + return namespace_is_accessible(normalized_uri, ctx) def _handle_agfs_read(self, result: Union[bytes, Any, None]) -> bytes: """Handle AGFSClient read return types consistently.""" @@ -1822,6 +1848,8 @@ async def append_file( existing_bytes = self._handle_agfs_read(self.agfs.read(path)) existing_bytes = await self._decrypt_content(existing_bytes, ctx=ctx) existing = self._decode_bytes(existing_bytes) + except FileNotFoundError: + pass except AGFSHTTPError as e: if e.status_code != 404: raise @@ -1892,19 +1920,22 @@ async def _ls_agent( if len(all_entries) >= node_limit: break name = entry.get("name", "") - # After modification: compatible with 7+ digits of microseconds by truncating raw_time = entry.get("modTime", "") - if raw_time and len(raw_time) > 26 and "+" in raw_time: - # Handle strings like 2026-02-21T13:20:23.1470042+08:00 - # Truncate to 2026-02-21T13:20:23.147004+08:00 - parts = raw_time.split("+") - # Keep time part at most 26 characters (YYYY-MM-DDTHH:MM:SS.mmmmmm) - raw_time = parts[0][:26] + "+" + parts[1] + parsed_time = now + if isinstance(raw_time, (int, float)): + parsed_time = datetime.fromtimestamp(raw_time) + elif raw_time: + if len(raw_time) > 26 and "+" in raw_time: + parts = raw_time.split("+") + raw_time = parts[0][:26] + "+" + parts[1] + parsed_time = parse_iso_datetime(raw_time) + elif isinstance(entry.get("mtime"), (int, float)): + parsed_time = datetime.fromtimestamp(entry["mtime"]) new_entry = { "uri": self._path_to_uri(f"{path}/{name}", ctx=ctx), "size": entry.get("size", 0), "isDir": entry.get("isDir", False), - "modTime": format_simplified(parse_iso_datetime(raw_time), now), + "modTime": format_simplified(parsed_time, now), } if not self._is_accessible(new_entry["uri"], real_ctx): continue diff --git a/openviking/storage/viking_vector_index_backend.py b/openviking/storage/viking_vector_index_backend.py index adaf3a1df..52b3a4e42 100644 --- a/openviking/storage/viking_vector_index_backend.py +++ b/openviking/storage/viking_vector_index_backend.py @@ -7,6 +7,7 @@ import uuid from typing import Any, Dict, List, Optional +from openviking.core.namespace import canonicalize_uri, visible_roots from openviking.server.identity import RequestContext, Role from openviking.storage.expr import And, Eq, FilterExpr, In, Or, PathScope, RawDSL from openviking.storage.vectordb.collection.collection import Collection @@ -41,7 +42,8 @@ "active_count", "level", "account_id", - "owner_space", + "owner_user_id", + "owner_agent_id", ] FETCH_BY_URI_OUTPUT_FIELDS = [ @@ -57,7 +59,8 @@ "tags", "abstract", "account_id", - "owner_space", + "owner_user_id", + "owner_agent_id", ] URI_REWRITE_OUTPUT_FIELDS = [ @@ -75,7 +78,8 @@ "tags", "abstract", "account_id", - "owner_space", + "owner_user_id", + "owner_agent_id", ] @@ -851,9 +855,9 @@ async def search_children_in_tenant( limit: int = 10, ) -> List[Dict[str, Any]]: # TODO:Better Alternative to Current Temporary Fix - - # If parent_uri is already under the requested target_directories, - # adding a redundant scope prefix filter can slow down the backend. + + # If parent_uri is already under the requested target_directories, + # adding a redundant scope prefix filter can slow down the backend. # Keep tenant/context filters but skip target_directories in that case. effective_target_directories = target_directories if target_directories: @@ -898,10 +902,8 @@ async def search_similar_memories( Eq("level", 2), Eq("account_id", ctx.account_id), ] - if owner_space: - conds.append(Eq("owner_space", owner_space)) if category_uri_prefix: - conds.append(In("uri", [category_uri_prefix])) + conds.append(PathScope("uri", canonicalize_uri(category_uri_prefix, ctx), depth=-1)) backend = self._get_backend_for_context(ctx) return await backend.search( @@ -920,9 +922,10 @@ async def get_context_by_uri( *, ctx: RequestContext, ) -> List[Dict[str, Any]]: - conds: List[FilterExpr] = [PathScope("uri", uri, depth=0), Eq("account_id", ctx.account_id)] - if owner_space: - conds.append(Eq("owner_space", owner_space)) + conds: List[FilterExpr] = [ + PathScope("uri", canonicalize_uri(uri, ctx), depth=0), + Eq("account_id", ctx.account_id), + ] if level is not None: conds.append(Eq("level", level)) @@ -941,17 +944,11 @@ async def delete_account_data(self, account_id: str, *, ctx: RequestContext) -> async def delete_uris(self, ctx: RequestContext, uris: List[str]) -> None: for uri in uris: + canonical_uri = canonicalize_uri(uri, ctx) conds: List[FilterExpr] = [ Eq("account_id", ctx.account_id), - Or([Eq("uri", uri), In("uri", [f"{uri}/"])]), + Or([Eq("uri", canonical_uri), In("uri", [f"{canonical_uri}/"])]), ] - if ctx.role == Role.USER and uri.startswith(("viking://user/", "viking://agent/")): - owner_space = ( - ctx.user.user_space_name() - if uri.startswith("viking://user/") - else ctx.user.agent_space_name() - ) - conds.append(Eq("owner_space", owner_space)) backend = self._get_backend_for_context(ctx) await backend.delete_by_filter(And(conds)) @@ -965,16 +962,11 @@ async def update_uri_mapping( ) -> bool: import hashlib - conds: List[FilterExpr] = [Eq("uri", uri), Eq("account_id", ctx.account_id)] + canonical_uri = canonicalize_uri(uri, ctx) + canonical_new_uri = canonicalize_uri(new_uri, ctx) + conds: List[FilterExpr] = [Eq("uri", canonical_uri), Eq("account_id", ctx.account_id)] if levels: conds.append(In("level", levels)) - if ctx.role == Role.USER and uri.startswith(("viking://user/", "viking://agent/")): - owner_space = ( - ctx.user.user_space_name() - if uri.startswith("viking://user/") - else ctx.user.agent_space_name() - ) - conds.append(Eq("owner_space", owner_space)) records = await self.filter( filter=And(conds), @@ -1003,14 +995,14 @@ def _seed_uri_for_id(uri: str, level: int) -> str: except (TypeError, ValueError): level = 2 - seed_uri = _seed_uri_for_id(new_uri, level) + seed_uri = _seed_uri_for_id(canonical_new_uri, level) id_seed = f"{ctx.account_id}:{seed_uri}" new_id = hashlib.md5(id_seed.encode("utf-8")).hexdigest() updated = { **record, "id": new_id, - "uri": new_uri, + "uri": canonical_new_uri, } if await self.upsert(updated, ctx=ctx): success = True @@ -1061,7 +1053,7 @@ def _build_scope_filter( if target_directories: uri_conds = [ - PathScope("uri", target_dir, depth=-1) + PathScope("uri", canonicalize_uri(target_dir, ctx), depth=-1) for target_dir in target_directories if target_dir ] @@ -1084,31 +1076,11 @@ def _tenant_filter( if ctx.role == Role.ROOT: return None - user_spaces = [ctx.user.user_space_name(), ctx.user.agent_space_name()] - resource_spaces = [*user_spaces, ""] account_filter = Eq("account_id", ctx.account_id) - - if context_type == "resource": - return And([account_filter, In("owner_space", resource_spaces)]) - if context_type in {"memory", "skill"}: - return And([account_filter, In("owner_space", user_spaces)]) - - return And( - [ - account_filter, - Or( - [ - And([Eq("context_type", "resource"), In("owner_space", resource_spaces)]), - And( - [ - In("context_type", ["memory", "skill"]), - In("owner_space", user_spaces), - ] - ), - ] - ), - ] - ) + path_filter = Or([PathScope("uri", root, depth=-1) for root in visible_roots(ctx)]) + if context_type: + return And([account_filter, path_filter]) + return And([account_filter, path_filter]) @staticmethod def _merge_filters(*filters: Optional[FilterExpr]) -> Optional[FilterExpr]: diff --git a/openviking/sync_client.py b/openviking/sync_client.py index 48efaadb6..c12f8578c 100644 --- a/openviking/sync_client.py +++ b/openviking/sync_client.py @@ -77,6 +77,7 @@ def add_message( content: str | None = None, parts: list[dict] | None = None, created_at: str | None = None, + role_id: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -86,11 +87,12 @@ def add_message( content: Text content (simple mode) parts: Parts array (full Part support: TextPart, ContextPart, ToolPart) created_at: Message creation time (ISO format string). If not provided, current time is used. + role_id: Optional explicit actor identity. Omit to let the client/server derive it. If both content and parts are provided, parts takes precedence. """ return run_async( - self._async_client.add_message(session_id, role, content, parts, created_at) + self._async_client.add_message(session_id, role, content, parts, created_at, role_id) ) def commit_session( diff --git a/openviking/utils/embedding_utils.py b/openviking/utils/embedding_utils.py index ef9151e0b..e0e23ca1b 100644 --- a/openviking/utils/embedding_utils.py +++ b/openviking/utils/embedding_utils.py @@ -12,6 +12,7 @@ from openviking.core.context import Context, ContextLevel, ResourceContentType, Vectorize from openviking.core.directories import get_context_type_for_uri +from openviking.core.namespace import agent_space_fragment, user_space_fragment from openviking.server.identity import RequestContext from openviking.storage.queuefs import get_queue_manager from openviking.storage.queuefs.embedding_msg_converter import EmbeddingMsgConverter @@ -42,9 +43,9 @@ async def _decrement_embedding_tracker(semantic_msg_id: Optional[str], count: in def _owner_space_for_uri(uri: str, ctx: RequestContext) -> str: """Derive owner_space from a URI.""" if uri.startswith("viking://agent/"): - return ctx.user.agent_space_name() + return agent_space_fragment(ctx) if uri.startswith("viking://user/") or uri.startswith("viking://session/"): - return ctx.user.user_space_name() + return user_space_fragment(ctx) return "" diff --git a/openviking/utils/skill_processor.py b/openviking/utils/skill_processor.py index 458608ca2..665dda829 100644 --- a/openviking/utils/skill_processor.py +++ b/openviking/utils/skill_processor.py @@ -14,6 +14,7 @@ from openviking.core.context import Context, ContextType, Vectorize from openviking.core.mcp_converter import is_mcp_format, mcp_to_skill +from openviking.core.namespace import agent_space_fragment, canonical_agent_root from openviking.core.skill_loader import SkillLoader from openviking.server.identity import RequestContext from openviking.server.local_input_guard import deny_direct_local_skill_input @@ -80,14 +81,14 @@ async def process_skill( ) context = Context( - uri=f"viking://agent/skills/{skill_dict['name']}", - parent_uri="viking://agent/skills", + uri=f"{canonical_agent_root(ctx)}/skills/{skill_dict['name']}", + parent_uri=f"{canonical_agent_root(ctx)}/skills", is_leaf=False, abstract=skill_dict.get("description", ""), context_type=ContextType.SKILL.value, user=ctx.user, account_id=ctx.account_id, - owner_space=ctx.user.agent_space_name(), + owner_space=agent_space_fragment(ctx), meta={ "name": skill_dict["name"], "description": skill_dict.get("description", ""), diff --git a/openviking_cli/client/base.py b/openviking_cli/client/base.py index 51abb0c8e..e06fb826e 100644 --- a/openviking_cli/client/base.py +++ b/openviking_cli/client/base.py @@ -263,6 +263,7 @@ async def add_message( content: str | None = None, parts: list[dict] | None = None, created_at: str | None = None, + role_id: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -272,6 +273,7 @@ async def add_message( content: Text content (simple mode) parts: Parts array (full Part support: TextPart, ContextPart, ToolPart) created_at: Message creation time (ISO format string) + role_id: Optional explicit actor identity. Omit to let the server derive it. If both content and parts are provided, parts takes precedence. """ diff --git a/openviking_cli/client/http.py b/openviking_cli/client/http.py index 198b50f2b..86e23468b 100644 --- a/openviking_cli/client/http.py +++ b/openviking_cli/client/http.py @@ -801,6 +801,7 @@ async def add_message( content: str | None = None, parts: list[dict] | None = None, created_at: str | None = None, + role_id: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -810,6 +811,7 @@ async def add_message( content: Text content (simple mode, backward compatible) parts: Parts array (full Part support mode) created_at: Message creation time (ISO format string) + role_id: Optional explicit actor identity. Omit to let the server derive it. If both content and parts are provided, parts takes precedence. """ @@ -823,6 +825,8 @@ async def add_message( if created_at is not None: payload["created_at"] = created_at + if role_id is not None: + payload["role_id"] = role_id response = await self._http.post( f"/api/v1/sessions/{session_id}/messages", diff --git a/openviking_cli/client/sync_http.py b/openviking_cli/client/sync_http.py index 2da8c2d8b..1e3cd3a92 100644 --- a/openviking_cli/client/sync_http.py +++ b/openviking_cli/client/sync_http.py @@ -114,6 +114,7 @@ def add_message( content: str | None = None, parts: list[dict] | None = None, created_at: str | None = None, + role_id: str | None = None, ) -> Dict[str, Any]: """Add a message to a session. @@ -123,11 +124,12 @@ def add_message( content: Text content (simple mode) parts: Parts array (full Part support: TextPart, ContextPart, ToolPart) created_at: Message creation time (ISO format string) + role_id: Optional explicit actor identity. Omit to let the server derive it. If both content and parts are provided, parts takes precedence. """ return run_async( - self._async_client.add_message(session_id, role, content, parts, created_at) + self._async_client.add_message(session_id, role, content, parts, created_at, role_id) ) def get_task(self, task_id: str) -> Optional[Dict[str, Any]]: diff --git a/openviking_cli/session/user_id.py b/openviking_cli/session/user_id.py index 5d221a6d3..235a164fd 100644 --- a/openviking_cli/session/user_id.py +++ b/openviking_cli/session/user_id.py @@ -50,22 +50,11 @@ def user_space_name(self) -> str: return self._user_id def _agent_space_source(self) -> str: - """Return the source string used to derive the agent-level space.""" - scope_mode = "user+agent" - try: - from openviking_cli.utils.config import get_openviking_config - - scope_mode = get_openviking_config().memory.agent_scope_mode - except Exception: - # Fall back to the legacy, fully isolated behavior when config is unavailable. - scope_mode = "user+agent" - - if scope_mode == "agent": - return self._agent_id + """Return the legacy source string used by deprecated hash-based agent helpers.""" return f"{self._user_id}:{self._agent_id}" def agent_space_name(self) -> str: - """Agent-level space name derived from memory.agent_scope_mode.""" + """Legacy hash-based agent space helper kept for backward compatibility.""" return hashlib.md5(self._agent_space_source().encode()).hexdigest()[:12] def memory_space_uri(self) -> str: diff --git a/openviking_cli/utils/config/memory_config.py b/openviking_cli/utils/config/memory_config.py index 075376906..7157913d6 100644 --- a/openviking_cli/utils/config/memory_config.py +++ b/openviking_cli/utils/config/memory_config.py @@ -15,8 +15,8 @@ class MemoryConfig(BaseModel): agent_scope_mode: str = Field( default="user+agent", description=( - "Agent memory namespace mode: 'user+agent' keeps agent memory isolated by " - "(user_id, agent_id), while 'agent' shares agent memory across users of the same agent." + "Deprecated and ignored. Kept only for backward compatibility with older ov.conf files. " + "Agent/user namespace behavior is now controlled by per-account namespace policy." ), ) diff --git a/openviking_cli/utils/config/open_viking_config.py b/openviking_cli/utils/config/open_viking_config.py index 050b6d5e3..035c33e3c 100644 --- a/openviking_cli/utils/config/open_viking_config.py +++ b/openviking_cli/utils/config/open_viking_config.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. # SPDX-License-Identifier: AGPL-3.0 import json +import logging import os from pathlib import Path from threading import Lock @@ -228,6 +229,15 @@ def from_dict(cls, config: Dict[str, Any]) -> "OpenVikingConfig": # Apply memory configuration if memory_config_data is not None: + if ( + isinstance(memory_config_data, dict) + and "agent_scope_mode" in memory_config_data + ): + logging.getLogger(__name__).warning( + "memory.agent_scope_mode is deprecated and ignored. " + "User/agent namespace behavior is now controlled by per-account " + "namespace policy." + ) instance.memory = MemoryConfig.from_dict(memory_config_data) # Apply parser configurations @@ -245,8 +255,6 @@ def from_dict(cls, config: Dict[str, Any]) -> "OpenVikingConfig": db_dim = instance.storage.vectordb.dimension emb_dim = instance.embedding.dimension if db_dim > 0 and emb_dim > 0 and db_dim != emb_dim: - import logging - logging.warning( f"Dimension mismatch: VectorDB dimension is {db_dim}, " f"but Embedding dimension is {emb_dim}. " @@ -336,9 +344,7 @@ def initialize( if config_dict is not None: cls._instance = OpenVikingConfig.from_dict(config_dict) else: - path = resolve_config_path( - config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF - ) + path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF) if path is not None: cls._instance = cls._load_from_file(str(path)) else: diff --git a/tests/api_test/api/client.py b/tests/api_test/api/client.py index 4fbfd12cf..30cf2ca5a 100644 --- a/tests/api_test/api/client.py +++ b/tests/api_test/api/client.py @@ -19,12 +19,14 @@ def __init__( api_key: Optional[str] = None, account: Optional[str] = None, user: Optional[str] = None, + agent: Optional[str] = None, ): self.base_url = base_url or Config.CONSOLE_URL self.server_url = server_url or Config.SERVER_URL self.api_key = api_key or Config.OPENVIKING_API_KEY self.account = account or Config.OPENVIKING_ACCOUNT self.user = user or Config.OPENVIKING_USER + self.agent = agent or Config.OPENVIKING_AGENT self.session = requests.Session() self._setup_default_headers() self.max_retries = 3 @@ -108,6 +110,7 @@ def _setup_default_headers(self): "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "X-OpenViking-Account": self.account, "X-OpenViking-User": self.user, + "X-OpenViking-Agent": self.agent, } if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" @@ -312,10 +315,19 @@ def delete_session(self, session_id: str) -> requests.Response: url = self._build_url(self.server_url, endpoint) return self._request_with_retry("DELETE", url) - def add_message(self, session_id: str, role: str, content: str) -> requests.Response: + def add_message( + self, + session_id: str, + role: str, + content: str, + role_id: Optional[str] = None, + ) -> requests.Response: endpoint = f"/api/v1/sessions/{session_id}/messages" url = self._build_url(self.server_url, endpoint) - return self._request_with_retry("POST", url, json={"role": role, "content": content}) + payload = {"role": role, "content": content} + if role_id is not None: + payload["role_id"] = role_id + return self._request_with_retry("POST", url, json=payload) def fs_ls(self, uri: str, simple: bool = False, recursive: bool = False) -> requests.Response: endpoint = "/api/v1/fs/ls" diff --git a/tests/api_test/tools/config/config.py b/tests/api_test/tools/config/config.py index 86ba8d317..e21417a76 100644 --- a/tests/api_test/tools/config/config.py +++ b/tests/api_test/tools/config/config.py @@ -13,6 +13,7 @@ class Config: OPENVIKING_API_KEY = os.getenv("OPENVIKING_API_KEY", "test-root-api-key") OPENVIKING_ACCOUNT = os.getenv("OPENVIKING_ACCOUNT", "default") OPENVIKING_USER = os.getenv("OPENVIKING_USER", "default") + OPENVIKING_AGENT = os.getenv("OPENVIKING_AGENT", "default") SERVER_STARTUP_TIMEOUT = 30 CONSOLE_STARTUP_TIMEOUT = 30 diff --git a/tests/cli/test_user_identifier.py b/tests/cli/test_user_identifier.py index dfddcf2bc..90049f7cc 100644 --- a/tests/cli/test_user_identifier.py +++ b/tests/cli/test_user_identifier.py @@ -1,10 +1,7 @@ """Tests for UserIdentifier, specifically agent_space_name collision safety.""" from openviking_cli.session.user_id import UserIdentifier -from openviking_cli.utils.config import ( - OpenVikingConfigSingleton, - get_openviking_config, -) +from openviking_cli.utils.config import OpenVikingConfigSingleton class TestAgentSpaceNameCollision: @@ -35,23 +32,10 @@ def test_hash_length(self): assert len(name) == 12 assert all(c in "0123456789abcdef" for c in name) - def test_agent_scope_mode_agent_shares_across_users(self): - """When memory.agent_scope_mode=agent, users of the same agent share the same space.""" + def test_agent_scope_mode_is_ignored(self): + """Deprecated memory.agent_scope_mode no longer changes agent_space_name.""" OpenVikingConfigSingleton.reset_instance() - config = get_openviking_config() - config.memory.agent_scope_mode = "agent" - - u1 = UserIdentifier("acct", "alice", "bot") - u2 = UserIdentifier("acct", "bob", "bot") - assert u1.agent_space_name() == u2.agent_space_name() - - OpenVikingConfigSingleton.reset_instance() - - def test_agent_scope_mode_user_and_agent_keeps_user_isolation(self): - """When memory.agent_scope_mode=user+agent, different users remain isolated.""" - OpenVikingConfigSingleton.reset_instance() - config = get_openviking_config() - config.memory.agent_scope_mode = "user+agent" + OpenVikingConfigSingleton.initialize(config_dict={"memory": {"agent_scope_mode": "agent"}}) u1 = UserIdentifier("acct", "alice", "bot") u2 = UserIdentifier("acct", "bob", "bot") diff --git a/tests/integration/test_compressor_v2_e2e.py b/tests/integration/test_compressor_v2_e2e.py index e5a71598f..43d82b957 100644 --- a/tests/integration/test_compressor_v2_e2e.py +++ b/tests/integration/test_compressor_v2_e2e.py @@ -198,30 +198,25 @@ async def print_memory_files(uri_prefix: str, memories_list: list): except Exception as e: print(f" Failed to read {uri}: {e}") - user_space = client._user.user_space_name() - agent_space = client._user.agent_space_name() - try: # Try to list agent memories - agent_memories = await client.ls( - f"viking://agent/{agent_space}/memories", recursive=True - ) + agent_memories = await client.ls("viking://agent/memories", recursive=True) print(f"Agent memories entries: {len(agent_memories)}") for entry in agent_memories[:20]: # Show first 20 print(f" - {entry['name']} ({'dir' if entry['isDir'] else 'file'})") # Read and print memory files - await print_memory_files(f"viking://agent/{agent_space}/memories", agent_memories) + await print_memory_files("viking://agent/memories", agent_memories) except Exception as e: print(f"Could not list agent memories: {e}") try: # Try to list user memories - user_memories = await client.ls(f"viking://user/{user_space}/memories", recursive=True) + user_memories = await client.ls("viking://user/memories", recursive=True) print(f"\nUser memories entries: {len(user_memories)}") for entry in user_memories[:20]: # Show first 20 print(f" - {entry['name']} ({'dir' if entry['isDir'] else 'file'})") # Read and print memory files - await print_memory_files(f"viking://user/{user_space}/memories", user_memories) + await print_memory_files("viking://user/memories", user_memories) except Exception as e: print(f"Could not list user memories: {e}") diff --git a/tests/server/test_api_key_manager.py b/tests/server/test_api_key_manager.py index 1055dd938..4a43d81fb 100644 --- a/tests/server/test_api_key_manager.py +++ b/tests/server/test_api_key_manager.py @@ -9,7 +9,7 @@ import pytest_asyncio from openviking.server.api_keys import APIKeyManager -from openviking.server.identity import Role +from openviking.server.identity import AccountNamespacePolicy, Role from openviking.service.core import OpenVikingService from openviking_cli.exceptions import AlreadyExistsError, NotFoundError, UnauthenticatedError from openviking_cli.session.user_id import UserIdentifier @@ -111,6 +111,7 @@ async def test_default_account_exists(manager: APIKeyManager): """Default account should be created on load.""" accounts = manager.get_accounts() assert any(a["account_id"] == "default" for a in accounts) + assert manager.get_account_policy("default") == AccountNamespacePolicy() # ---- User lifecycle tests ---- @@ -223,6 +224,40 @@ async def test_persistence_across_reload(manager_service): assert identity.role == Role.ADMIN +async def test_legacy_account_without_settings_infers_user_and_agent_policy(manager_service): + """Legacy accounts default to user-shared + agent-isolated and persist the inferred policy.""" + acct = _uid() + created_at = "2026-04-16T00:00:00+00:00" + + seed_mgr = APIKeyManager(root_key=ROOT_KEY, viking_fs=manager_service.viking_fs) + await seed_mgr._write_json( + "/local/_system/accounts.json", {"accounts": {acct: {"created_at": created_at}}} + ) + await seed_mgr._write_json( + f"/local/{acct}/_system/users.json", + {"users": {"alice": {"role": "admin", "key": "legacy-key-alice"}}}, + ) + + mgr = APIKeyManager( + root_key=ROOT_KEY, + viking_fs=manager_service.viking_fs, + ) + await mgr.load() + + assert mgr.get_account_policy(acct) == AccountNamespacePolicy( + isolate_user_scope_by_agent=False, + isolate_agent_scope_by_user=True, + ) + + settings = await mgr._read_json(f"/local/{acct}/_system/setting.json") + assert settings == { + "namespace": { + "isolate_user_scope_by_agent": False, + "isolate_agent_scope_by_user": True, + } + } + + # ---- Encryption tests ---- diff --git a/tests/server/test_api_search.py b/tests/server/test_api_search.py index f3f376468..80453252c 100644 --- a/tests/server/test_api_search.py +++ b/tests/server/test_api_search.py @@ -9,7 +9,10 @@ import pytest from openviking.models.embedder.base import EmbedResult +from openviking.server.auth import get_request_context +from openviking.server.identity import RequestContext, Role from openviking.utils.time_utils import parse_iso_datetime +from openviking_cli.session.user_id import UserIdentifier @pytest.fixture(autouse=True) @@ -45,6 +48,28 @@ async def test_find_with_target_uri(client_with_resource): assert resp.json()["status"] == "ok" +async def test_find_with_inaccessible_target_uri_returns_permission_denied( + client: httpx.AsyncClient, app +): + app.dependency_overrides[get_request_context] = lambda: RequestContext( + user=UserIdentifier.the_default_user(), + role=Role.USER, + ) + try: + resp = await client.post( + "/api/v1/search/find", + json={"query": "sample", "target_uri": "viking://agent/foreign-agent", "limit": 5}, + ) + finally: + app.dependency_overrides.pop(get_request_context, None) + + assert resp.status_code == 403 + body = resp.json() + assert body["status"] == "error" + assert body["error"]["code"] == "PERMISSION_DENIED" + assert "Access denied" in body["error"]["message"] + + async def test_find_with_score_threshold(client_with_resource): client, uri = client_with_resource resp = await client.post( diff --git a/tests/server/test_api_sessions.py b/tests/server/test_api_sessions.py index d4070b463..05a8d9380 100644 --- a/tests/server/test_api_sessions.py +++ b/tests/server/test_api_sessions.py @@ -9,14 +9,45 @@ import httpx import pytest +from fastapi import FastAPI +from starlette.requests import Request from openviking.message import Message +from openviking.server.api_keys import APIKeyManager +from openviking.server.config import ServerConfig from openviking.server.identity import RequestContext, Role +from openviking.server.routers import sessions as sessions_router +from openviking_cli.exceptions import InvalidArgumentError from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils.config import OPENVIKING_CONFIG_ENV from openviking_cli.utils.config.open_viking_config import OpenVikingConfigSingleton from tests.utils.mock_agfs import MockLocalAGFS +DEFAULT_USER = UserIdentifier.the_default_user() +TEST_ROOT_KEY = "root-secret-key-for-session-tests" +_UNSET = object() + + +def _message_request( + role: str, + *, + content: str | None = None, + parts: list[dict] | None = None, + role_id: object = _UNSET, +) -> dict: + payload = {"role": role} + if content is not None: + payload["content"] = content + if parts is not None: + payload["parts"] = parts + if role_id is _UNSET and role == "user": + payload["role_id"] = DEFAULT_USER.user_id + elif role_id is _UNSET and role == "assistant": + payload["role_id"] = DEFAULT_USER.agent_id + elif role_id is not None: + payload["role_id"] = role_id + return payload + @pytest.fixture(autouse=True) def _configure_test_env(monkeypatch, tmp_path): @@ -65,6 +96,45 @@ async def _wait_for_task(client: httpx.AsyncClient, task_id: str, timeout: float raise TimeoutError(f"Task {task_id} did not complete within {timeout}s") +def _session_route_request( + *, + auth_mode: str = "api_key", + api_key_manager=None, +) -> Request: + app = FastAPI() + app.state.config = ServerConfig(auth_mode=auth_mode) + app.state.api_key_manager = api_key_manager + scope = { + "type": "http", + "path": "/api/v1/sessions/test-session/messages", + "headers": [], + "app": app, + } + return Request(scope) + + +async def _call_add_message_route( + service, + monkeypatch, + *, + ctx: RequestContext, + payload: dict, + auth_mode: str = "api_key", + api_key_manager=None, + session_id: str = "test-session", +): + monkeypatch.setattr(sessions_router, "get_service", lambda: service) + return await sessions_router.add_message( + request=sessions_router.AddMessageRequest.model_validate(payload), + http_request=_session_route_request( + auth_mode=auth_mode, + api_key_manager=api_key_manager, + ), + session_id=session_id, + _ctx=ctx, + ) + + async def test_create_session(client: httpx.AsyncClient): resp = await client.post("/api/v1/sessions", json={}) assert resp.status_code == 200 @@ -100,7 +170,7 @@ async def test_get_session_context(client: httpx.AsyncClient): await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Current live message"}, + json=_message_request("user", content="Current live message"), ) resp = await client.get(f"/api/v1/sessions/{session_id}/context") @@ -120,7 +190,7 @@ async def test_get_session_context_includes_incomplete_archive_messages( await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Archived seed"}, + json=_message_request("user", content="Archived seed"), ) commit_resp = await client.post(f"/api/v1/sessions/{session_id}/commit") assert commit_resp.status_code == 200 @@ -129,8 +199,11 @@ async def test_get_session_context_includes_incomplete_archive_messages( session = service.sessions.session(ctx, session_id) await session.load() pending_messages = [ - Message.create_user("Pending user message"), - Message.create_assistant("Pending assistant response"), + Message.create_user("Pending user message", role_id=DEFAULT_USER.user_id), + Message.create_assistant( + "Pending assistant response", + role_id=DEFAULT_USER.agent_id, + ), ] await session._viking_fs.write_file( uri=f"{session.uri}/history/archive_002/messages.jsonl", @@ -140,7 +213,7 @@ async def test_get_session_context_includes_incomplete_archive_messages( await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Current live message"}, + json=_message_request("user", content="Current live message"), ) resp = await client.get(f"/api/v1/sessions/{session_id}/context") @@ -159,7 +232,7 @@ async def test_add_message(client: httpx.AsyncClient): resp = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Hello, world!"}, + json=_message_request("user", content="Hello, world!"), ) assert resp.status_code == 200 body = resp.json() @@ -167,6 +240,133 @@ async def test_add_message(client: httpx.AsyncClient): assert body["result"]["message_count"] == 1 +async def test_add_message_root_request_autofills_role_id(service, monkeypatch): + session_id = "root-auto-fill" + ctx = RequestContext(user=DEFAULT_USER, role=Role.ROOT) + + response = await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("user", content="hello root", role_id=None), + session_id=session_id, + ) + + assert response.result["message_count"] == 1 + session = await service.sessions.get(session_id, ctx, auto_create=False) + await session.load() + assert session.messages[-1].role_id == DEFAULT_USER.user_id + + +async def test_add_message_trusted_request_allows_explicit_role_id(service, monkeypatch): + session_id = "trusted-explicit-role-id" + ctx = RequestContext( + user=UserIdentifier("acct_trusted", "caller", "assistant-a"), + role=Role.USER, + ) + + response = await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("assistant", content="hello trusted", role_id="assistant-b"), + auth_mode="trusted", + session_id=session_id, + ) + + assert response.result["message_count"] == 1 + session = await service.sessions.get(session_id, ctx, auto_create=False) + await session.load() + assert session.messages[-1].role_id == "assistant-b" + + +async def test_add_message_admin_request_allows_registered_user_role_id(service, monkeypatch): + manager = APIKeyManager(root_key=TEST_ROOT_KEY, viking_fs=service.viking_fs) + await manager.load() + account_id = "acct_session_admin" + await manager.create_account(account_id, "admin_user") + await manager.register_user(account_id, "alice") + + ctx = RequestContext( + user=UserIdentifier(account_id, "admin_user", "assistant-admin"), + role=Role.ADMIN, + ) + session_id = "admin-explicit-role-id" + + response = await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("user", content="hello admin", role_id="alice"), + api_key_manager=manager, + session_id=session_id, + ) + + assert response.result["message_count"] == 1 + session = await service.sessions.get(session_id, ctx, auto_create=False) + await session.load() + assert session.messages[-1].role_id == "alice" + + +async def test_add_message_user_request_rejects_explicit_role_id(service, monkeypatch): + ctx = RequestContext( + user=UserIdentifier("acct_session_user", "alice", "assistant-user"), + role=Role.USER, + ) + + with pytest.raises(InvalidArgumentError, match="cannot explicitly set role_id"): + await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("user", content="hello user", role_id="alice"), + session_id="user-explicit-role-id", + ) + + +async def test_add_message_user_request_autofills_role_id(service, monkeypatch): + session_id = "user-auto-fill-role-id" + ctx = RequestContext( + user=UserIdentifier("acct_session_user", "alice", "assistant-user"), + role=Role.USER, + ) + + response = await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("assistant", content="hello user", role_id=None), + session_id=session_id, + ) + + assert response.result["message_count"] == 1 + session = await service.sessions.get(session_id, ctx, auto_create=False) + await session.load() + assert session.messages[-1].role_id == "assistant-user" + + +async def test_add_message_rejects_unregistered_user_role_id(service, monkeypatch): + manager = APIKeyManager(root_key=TEST_ROOT_KEY, viking_fs=service.viking_fs) + await manager.load() + account_id = "acct_session_invalid" + await manager.create_account(account_id, "admin_user") + + ctx = RequestContext( + user=UserIdentifier(account_id, "admin_user", "assistant-admin"), + role=Role.ADMIN, + ) + + with pytest.raises(InvalidArgumentError, match="not a registered user"): + await _call_add_message_route( + service, + monkeypatch, + ctx=ctx, + payload=_message_request("user", content="hello invalid", role_id="ghost"), + api_key_manager=manager, + session_id="invalid-user-role-id", + ) + + async def test_add_multiple_messages(client: httpx.AsyncClient): create_resp = await client.post("/api/v1/sessions", json={}) session_id = create_resp.json()["result"]["session_id"] @@ -175,19 +375,19 @@ async def test_add_multiple_messages(client: httpx.AsyncClient): # the accumulated count (messages are loaded from storage each time) resp1 = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Message 0"}, + json=_message_request("user", content="Message 0"), ) assert resp1.json()["result"]["message_count"] >= 1 resp2 = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Message 1"}, + json=_message_request("user", content="Message 1"), ) count2 = resp2.json()["result"]["message_count"] resp3 = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Message 2"}, + json=_message_request("user", content="Message 2"), ) count3 = resp3.json()["result"]["message_count"] @@ -202,14 +402,14 @@ async def test_add_message_persistence_regression(client: httpx.AsyncClient, ser resp1 = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Message A"}, + json=_message_request("user", content="Message A"), ) assert resp1.status_code == 200 assert resp1.json()["result"]["message_count"] == 1 resp2 = await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Message B"}, + json=_message_request("user", content="Message B"), ) assert resp2.status_code == 200 assert resp2.json()["result"]["message_count"] == 2 @@ -235,7 +435,7 @@ async def test_delete_session(client: httpx.AsyncClient): # Add a message so the session file exists in storage await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "ensure persisted"}, + json=_message_request("user", content="ensure persisted"), ) # Compress to persist await client.post(f"/api/v1/sessions/{session_id}/commit") @@ -252,7 +452,7 @@ async def test_compress_session(client: httpx.AsyncClient): # Add some messages before committing await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "Hello"}, + json=_message_request("user", content="Hello"), ) # Default wait=False: returns accepted with task_id @@ -300,7 +500,7 @@ async def test_get_session_context_endpoint_returns_trimmed_latest_archive_and_m await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "archived message"}, + json=_message_request("user", content="archived message"), ) commit_resp = await client.post(f"/api/v1/sessions/{session_id}/commit") task_id = commit_resp.json()["result"]["task_id"] @@ -308,9 +508,9 @@ async def test_get_session_context_endpoint_returns_trimmed_latest_archive_and_m await client.post( f"/api/v1/sessions/{session_id}/messages", - json={ - "role": "assistant", - "parts": [ + json=_message_request( + "assistant", + parts=[ {"type": "text", "text": "Running tool"}, { "type": "tool", @@ -321,7 +521,7 @@ async def test_get_session_context_endpoint_returns_trimmed_latest_archive_and_m "tool_status": "running", }, ], - }, + ), ) resp = await client.get(f"/api/v1/sessions/{session_id}/context?token_budget=1") @@ -350,11 +550,11 @@ async def test_get_session_archive_endpoint_returns_archive_details(client: http await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "archived question"}, + json=_message_request("user", content="archived question"), ) await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "assistant", "content": "archived answer"}, + json=_message_request("assistant", content="archived answer"), ) commit_resp = await client.post(f"/api/v1/sessions/{session_id}/commit") task_id = commit_resp.json()["result"]["task_id"] @@ -388,7 +588,7 @@ async def failing_extract(*args, **kwargs): await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "first round"}, + json=_message_request("user", content="first round"), ) commit_resp = await client.post(f"/api/v1/sessions/{session_id}/commit") task_id = commit_resp.json()["result"]["task_id"] @@ -397,7 +597,7 @@ async def failing_extract(*args, **kwargs): await client.post( f"/api/v1/sessions/{session_id}/messages", - json={"role": "user", "content": "second round"}, + json=_message_request("user", content="second round"), ) resp = await client.post(f"/api/v1/sessions/{session_id}/commit") diff --git a/tests/server/test_auth.py b/tests/server/test_auth.py index aa3a7e152..b8e7c9041 100644 --- a/tests/server/test_auth.py +++ b/tests/server/test_auth.py @@ -24,7 +24,7 @@ from openviking.server.models import ERROR_CODE_TO_HTTP_STATUS, ErrorInfo, Response from openviking.service.core import OpenVikingService from openviking.service.task_tracker import get_task_tracker, reset_task_tracker -from openviking_cli.exceptions import InvalidArgumentError, OpenVikingError +from openviking_cli.exceptions import InvalidArgumentError, OpenVikingError, PermissionDeniedError from openviking_cli.session.user_id import UserIdentifier @@ -41,6 +41,7 @@ def _make_request( auth_enabled: bool = True, auth_mode: str = "api_key", root_api_key: str | None = None, + api_key_manager=None, ) -> Request: """Create a minimal Starlette request for auth dependency tests.""" raw_headers = [] @@ -50,7 +51,7 @@ def _make_request( app.state.config = ServerConfig(auth_mode=auth_mode, root_api_key=root_api_key) if auth_enabled: # Non-empty api_key_manager means the server is in authenticated mode. - app.state.api_key_manager = object() + app.state.api_key_manager = api_key_manager if api_key_manager is not None else object() scope = { "type": "http", "path": path, @@ -372,6 +373,109 @@ async def test_agent_id_header_forwarded(auth_client: httpx.AsyncClient): assert resp.status_code == 200 +async def test_admin_key_can_switch_effective_user_and_agent_within_account(auth_app): + """ADMIN keys may reuse X-OpenViking-User/Agent within their own account.""" + manager = auth_app.state.api_key_manager + account_id = _uid() + admin_key = await manager.create_account(account_id, "admin_user") + await manager.register_user(account_id, "alice") + + request = _make_request( + "/api/v1/resources", + headers={ + "X-API-Key": admin_key, + "X-OpenViking-Account": account_id, + "X-OpenViking-User": "alice", + "X-OpenViking-Agent": "assistant-2", + }, + auth_enabled=True, + api_key_manager=manager, + ) + + identity = await resolve_identity( + request, + x_api_key=admin_key, + x_openviking_account=account_id, + x_openviking_user="alice", + x_openviking_agent="assistant-2", + ) + + assert identity.role == Role.ADMIN + assert identity.account_id == account_id + assert identity.user_id == "alice" + assert identity.agent_id == "assistant-2" + + +async def test_admin_key_cannot_switch_account_via_header(auth_app): + """ADMIN keys must stay inside their own account.""" + manager = auth_app.state.api_key_manager + account_id = _uid() + admin_key = await manager.create_account(account_id, "admin_user") + + request = _make_request( + "/api/v1/resources", + headers={ + "X-API-Key": admin_key, + "X-OpenViking-Account": "other-account", + }, + auth_enabled=True, + api_key_manager=manager, + ) + + with pytest.raises(PermissionDeniedError, match="X-OpenViking-Account"): + await resolve_identity( + request, + x_api_key=admin_key, + x_openviking_account="other-account", + ) + + +async def test_user_key_can_switch_agent_but_not_user(auth_app): + """USER keys may set agent context but may not impersonate another user.""" + manager = auth_app.state.api_key_manager + account_id = _uid() + await manager.create_account(account_id, "admin_user") + user_key = await manager.register_user(account_id, "alice") + + request = _make_request( + "/api/v1/resources", + headers={ + "X-API-Key": user_key, + "X-OpenViking-Agent": "assistant-7", + }, + auth_enabled=True, + api_key_manager=manager, + ) + + identity = await resolve_identity( + request, + x_api_key=user_key, + x_openviking_agent="assistant-7", + ) + + assert identity.role == Role.USER + assert identity.account_id == account_id + assert identity.user_id == "alice" + assert identity.agent_id == "assistant-7" + + forbidden_request = _make_request( + "/api/v1/resources", + headers={ + "X-API-Key": user_key, + "X-OpenViking-User": "bob", + }, + auth_enabled=True, + api_key_manager=manager, + ) + + with pytest.raises(PermissionDeniedError, match="X-OpenViking-User"): + await resolve_identity( + forbidden_request, + x_api_key=user_key, + x_openviking_user="bob", + ) + + async def test_cross_tenant_session_get_returns_not_found(auth_client: httpx.AsyncClient, auth_app): """A user must not access another tenant's session by session_id.""" manager = auth_app.state.api_key_manager diff --git a/tests/session/memory/test_memory_updater.py b/tests/session/memory/test_memory_updater.py index 0aaf1244c..0d198d73f 100644 --- a/tests/session/memory/test_memory_updater.py +++ b/tests/session/memory/test_memory_updater.py @@ -4,10 +4,13 @@ Tests for MemoryUpdater. """ +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest +from openviking.server.identity import AccountNamespacePolicy, RequestContext, Role +from openviking.session.memory.dataclass import MemoryTypeSchema from openviking.session.memory.memory_type_registry import MemoryTypeRegistry from openviking.session.memory.memory_updater import ( MemoryUpdater, @@ -17,7 +20,13 @@ SearchReplaceBlock, StrPatch, ) -from openviking.session.memory.utils import deserialize_full, serialize_with_metadata +from openviking.session.memory.utils import ( + ResolvedOperation, + ResolvedOperations, + deserialize_full, + serialize_with_metadata, +) +from openviking_cli.session.user_id import UserIdentifier class TestMemoryUpdateResult: @@ -98,6 +107,88 @@ def test_set_registry(self): assert updater._registry == registry + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("policy", "schema_directory", "resolved_uri", "expected_directory", "memory_type"), + [ + ( + AccountNamespacePolicy( + isolate_user_scope_by_agent=True, + isolate_agent_scope_by_user=False, + ), + "viking://user/{{ user_space }}/memories/preferences", + "viking://user/alice/agent/bot/memories/preferences/theme.md", + "viking://user/alice/agent/bot/memories/preferences", + "preferences", + ), + ( + AccountNamespacePolicy( + isolate_user_scope_by_agent=False, + isolate_agent_scope_by_user=True, + ), + "viking://agent/{{ agent_space }}/memories/tools", + "viking://agent/bot/user/alice/memories/tools/web_search.md", + "viking://agent/bot/user/alice/memories/tools", + "tools", + ), + ], + ) + async def test_apply_operations_matches_overview_directories_with_namespace_policy( + self, + monkeypatch, + policy, + schema_directory, + resolved_uri, + expected_directory, + memory_type, + ): + """Overview generation should use policy-expanded user/agent space fragments.""" + schema = MemoryTypeSchema( + memory_type=memory_type, + description=f"{memory_type} memory", + directory=schema_directory, + filename_template="{{ name }}.md", + fields=[], + overview_template="overview", + ) + registry = MagicMock() + registry.list_all.return_value = [schema] + + updater = MemoryUpdater(registry=registry) + updater._get_viking_fs = MagicMock(return_value=MagicMock()) + updater._apply_edit = AsyncMock(return_value=False) + updater._vectorize_memories = AsyncMock() + updater.generate_overview = AsyncMock() + + resolved = ResolvedOperations() + resolved.operations.append( + ResolvedOperation( + model={"name": "demo"}, + uri=resolved_uri, + memory_type=memory_type, + ) + ) + monkeypatch.setattr( + "openviking.session.memory.memory_updater.resolve_all_operations", + lambda *args, **kwargs: resolved, + ) + + ctx = RequestContext( + user=UserIdentifier("acme", "alice", "bot"), + role=Role.USER, + namespace_policy=policy, + ) + + result = await updater.apply_operations(operations=SimpleNamespace(), ctx=ctx) + + assert result.written_uris == [resolved_uri] + updater.generate_overview.assert_awaited_once_with( + memory_type, + expected_directory, + ctx, + None, + ) + # The TestApplyWriteWithContentInFields tests are outdated because WriteOp no longer exists # The _apply_write method now accepts any flat model (dict or Pydantic model) that diff --git a/tests/storage/test_embedding_msg_converter_tenant.py b/tests/storage/test_embedding_msg_converter_tenant.py index 0a3e08c41..73bc5c95b 100644 --- a/tests/storage/test_embedding_msg_converter_tenant.py +++ b/tests/storage/test_embedding_msg_converter_tenant.py @@ -11,32 +11,47 @@ @pytest.mark.parametrize( - ("uri", "expected_space"), + ("uri", "expected_owner_user_id", "expected_owner_agent_id"), [ ( "viking://user/memories/preferences/me.md", - lambda user: user.user_space_name(), + lambda user: user.user_id, + None, ), ( "viking://agent/memories/cases/me.md", - lambda user: user.agent_space_name(), + None, + lambda user: user.agent_id, ), ( "viking://resources/doc.md", - lambda _user: "", + None, + None, ), ], ) -def test_embedding_msg_converter_backfills_account_and_owner_space(uri, expected_space): +def test_embedding_msg_converter_backfills_account_and_owner_fields( + uri, expected_owner_user_id, expected_owner_agent_id +): user = UserIdentifier("acme", "alice", "helper") context = Context(uri=uri, abstract="hello", user=user) # Simulate legacy producer that forgot tenant fields. context.account_id = "" - context.owner_space = "" + context.owner_user_id = None + context.owner_agent_id = None msg = EmbeddingMsgConverter.from_context(context) assert msg is not None assert msg.context_data["account_id"] == "acme" - assert msg.context_data["owner_space"] == expected_space(user) + expected_user = ( + expected_owner_user_id(user) if callable(expected_owner_user_id) else expected_owner_user_id + ) + expected_agent = ( + expected_owner_agent_id(user) + if callable(expected_owner_agent_id) + else expected_owner_agent_id + ) + assert msg.context_data["owner_user_id"] == expected_user + assert msg.context_data["owner_agent_id"] == expected_agent diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index bcb470d0c..39dc0b7ca 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -169,6 +169,23 @@ def test_openviking_config_rejects_unknown_top_level_section_with_suggestion(mon OpenVikingConfigSingleton.reset_instance() +def test_openviking_config_warns_when_agent_scope_mode_is_configured(monkeypatch, caplog): + monkeypatch.setenv("OPENVIKING_CONFIG_FILE", "/tmp/codex-no-config.json") + + from openviking_cli.utils.config.open_viking_config import ( + OpenVikingConfig, + OpenVikingConfigSingleton, + ) + + with caplog.at_level("WARNING"): + config = OpenVikingConfig.from_dict({"memory": {"agent_scope_mode": "agent"}}) + + assert config.memory.agent_scope_mode == "agent" + assert "memory.agent_scope_mode is deprecated and ignored" in caplog.text + + OpenVikingConfigSingleton.reset_instance() + + def test_openviking_config_singleton_preserves_value_error_for_bad_config(tmp_path, monkeypatch): monkeypatch.setenv(OPENVIKING_CONFIG_ENV, "/tmp/codex-no-config.json") diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 9a34d14da..039117351 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -482,33 +482,40 @@ def test_user_account_id_inheritance(self): assert ctx.account_id == user.account_id - def test_owner_space_agent(self): - """Test owner_space for agent URI.""" + def test_owner_fields_agent(self): + """Test owner fields for canonical agent URI.""" user = UserIdentifier(account_id="account-123", user_id="user-123", agent_id="agent-456") ctx = Context(uri="viking://agent/test/skills/my-skill/", user=user) - assert ctx.owner_space == user.agent_space_name() + assert ctx.owner_user_id is None + assert ctx.owner_agent_id == "test" + assert ctx.owner_space == user.agent_id - def test_owner_space_user(self): - """Test owner_space for user URI.""" + def test_owner_fields_user(self): + """Test owner fields for canonical user URI.""" user = UserIdentifier(account_id="account-123", user_id="user-123", agent_id="agent-456") ctx = Context(uri="viking://user/test/memories/test.md", user=user) - assert ctx.owner_space == user.user_space_name() + assert ctx.owner_user_id == "test" + assert ctx.owner_agent_id is None + assert ctx.owner_space == user.user_id - def test_owner_space_session(self): - """Test owner_space for session URI.""" + def test_owner_fields_session(self): + """Test owner fields for session URI.""" user = UserIdentifier(account_id="account-123", user_id="user-123", agent_id="agent-456") ctx = Context(uri="viking://session/test/msg/1.md", user=user) - assert ctx.owner_space == user.user_space_name() + assert ctx.owner_user_id is None + assert ctx.owner_agent_id is None + assert ctx.owner_space == user.user_id - def test_owner_space_resource_default(self): - """Test owner_space default for resource URI.""" + def test_owner_fields_resource_default(self): + """Test owner fields default for resource URI.""" user = UserIdentifier(account_id="account-123", user_id="user-123", agent_id="agent-456") ctx = Context(uri="viking://resources/docs/test.md", user=user) - # Resource URIs don't match agent/user/session patterns + assert ctx.owner_user_id is None + assert ctx.owner_agent_id is None assert ctx.owner_space == ""