Skip to content

Commit ef2513c

Browse files
jcp0578LinQiang391
andauthored
feat(openclaw-plugin): align auth, namespace, and role id handling (#1606)
* feat(plugin): multi-tenant identity, agent scope mode, and resource recall Made-with: Cursor * fix(openclaw-plugin): avoid default tenant fallback * feat(openclaw-plugin): add server auth mode handling * feat(openclaw-plugin): add server auth mode and canonical namespace config * feat(openclaw-plugin): propagate sender role id in session writes * test(openclaw-plugin): add multi-tenant coverage and Chinese report * docs(openclaw-plugin): update Chinese multi-tenant docs * fix(openclaw-plugin): align default namespace policy and shared role id logic * test(auth): drop unintended test_auth changes from plugin PR * fix(openclaw-plugin): preserve tenant headers for api key root flows --------- Co-authored-by: johnny <1667704220@qq.com>
1 parent c50d786 commit ef2513c

14 files changed

Lines changed: 1133 additions & 118 deletions

docs/zh/concepts/11-multi-tenant.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ openclaw config set plugins.entries.openviking.config.agentId "<agent-id>"
202202

203203
- 接入简单,插件不需要管理 account/user 生命周期
204204
- 最适合“一个 OpenClaw 实例对应一个 OpenViking 用户”的场景
205-
- `agentId` 决定 agent 级空间,便于区分不同 OpenClaw 实例或不同 agent 角色
205+
- `agentId` 参与决定 agent 级空间,便于区分不同 OpenClaw 实例或不同 agent 角色
206206
- 同一 account 内的 `resources` 可共享,`user` / `agent` memory 会按身份隔离
207207

208208
### OpenClaw 插件为何通常不配 `account` / `user`

examples/openclaw-plugin/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,32 @@ The main rules are:
5151

5252
This matters because the plugin is built to support multi-agent and multi-session OpenClaw usage without mixing memories across sessions.
5353

54+
The recommended remote-mode configuration only needs:
55+
56+
- `baseUrl`
57+
- `apiKey`
58+
- `agentId`
59+
60+
In this setup:
61+
62+
- `apiKey` should usually be a user key
63+
- `accountId` / `userId` are advanced options only for root-key or `trusted` deployments
64+
- `isolateUserScopeByAgent` / `isolateAgentScopeByUser` must match the server-side account namespace policy when using the PR #1356 canonical namespace model
65+
- `agentScopeMode` is a deprecated compatibility alias for older hash-based routing and should only be used against older servers
66+
67+
### Canonical namespace policy
68+
69+
For OpenViking servers that include PR #1356, the plugin no longer treats agent or user scope as a locally computed hash. Instead it expands shorthand aliases into canonical URIs using the configured namespace policy:
70+
71+
- `viking://user/memories`
72+
- `viking://user/<user_id>/memories` when `isolateUserScopeByAgent=false`
73+
- `viking://user/<user_id>/agent/<agent_id>/memories` when `isolateUserScopeByAgent=true`
74+
- `viking://agent/memories`
75+
- `viking://agent/<agent_id>/memories` when `isolateAgentScopeByUser=false`
76+
- `viking://agent/<agent_id>/user/<user_id>/memories` when `isolateAgentScopeByUser=true`
77+
78+
The plugin cannot auto-discover this policy today because `/api/v1/system/status` does not expose it. Configure the two booleans explicitly so they stay aligned with the server-side account policy.
79+
5480
## Prompt-Front Recall Flow
5581

5682
![Automatic recall flow before prompt build](./images/openclaw-plugin-recall-flow.png)

examples/openclaw-plugin/README_CN.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,32 @@
5151

5252
这样做是为了支持多 agent、多 session 并发时的记忆隔离,避免不同 OpenClaw 会话串用同一套长期上下文。
5353

54+
默认推荐的远程模式配置只有:
55+
56+
- `baseUrl`
57+
- `apiKey`
58+
- `agentId`
59+
60+
其中:
61+
62+
- `apiKey` 推荐使用某个 user 的 user key
63+
- `accountId` / `userId` 仅在 root key 或 `trusted` 模式下作为高级选项使用
64+
- 使用 PR #1356 canonical namespace 模型时,`isolateUserScopeByAgent` / `isolateAgentScopeByUser` 必须与服务端 account namespace policy 保持一致
65+
- `agentScopeMode` 已退化为兼容旧 hash 路由的 deprecated alias,仅应在旧服务端上使用
66+
67+
### Canonical namespace policy
68+
69+
对于包含 PR #1356 的 OpenViking 服务端,插件不再在本地计算 user 或 agent scope hash,而是根据配置的 namespace policy 将别名 URI 展开为 canonical URI:
70+
71+
- `viking://user/memories`
72+
- `isolateUserScopeByAgent=false` 时展开为 `viking://user/<user_id>/memories`
73+
- `isolateUserScopeByAgent=true` 时展开为 `viking://user/<user_id>/agent/<agent_id>/memories`
74+
- `viking://agent/memories`
75+
- `isolateAgentScopeByUser=false` 时展开为 `viking://agent/<agent_id>/memories`
76+
- `isolateAgentScopeByUser=true` 时展开为 `viking://agent/<agent_id>/user/<user_id>/memories`
77+
78+
插件当前无法从 `/api/v1/system/status` 自动发现这两个 policy,因此需要显式配置,使其与服务端 account policy 保持一致。
79+
5480
## Prompt 前召回链路
5581

5682
![Prompt 前的自动召回流程](./images/openclaw-plugin-recall-flow.png)

examples/openclaw-plugin/client.ts

Lines changed: 81 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHash, randomUUID } from "node:crypto";
1+
import { randomUUID } from "node:crypto";
22
import type { spawn } from "node:child_process";
33
import { once } from "node:events";
44
import { createWriteStream } from "node:fs";
@@ -27,6 +27,8 @@ export type FindResult = {
2727

2828
export type CaptureMode = "semantic" | "keyword";
2929
export type ScopeName = "user" | "agent";
30+
export type AgentScopeMode = "user_agent" | "agent";
31+
export type ServerAuthMode = "api_key" | "trusted";
3032
export type RuntimeIdentity = {
3133
userId: string;
3234
agentId: string;
@@ -171,17 +173,20 @@ export const localClientCache = new Map<string, LocalClientCacheEntry>();
171173
export const localClientPendingPromises = new Map<string, PendingClientEntry>();
172174

173175
const MEMORY_URI_PATTERNS = [
174-
/^viking:\/\/user\/(?:[^/]+\/)?memories(?:\/|$)/,
175-
/^viking:\/\/agent\/(?:[^/]+\/)?memories(?:\/|$)/,
176+
/^viking:\/\/user\/(?:[^/]+(?:\/agent\/[^/]+)?\/)?memories(?:\/|$)/,
177+
/^viking:\/\/agent\/(?:[^/]+(?:\/user\/[^/]+)?\/)?memories(?:\/|$)/,
176178
];
177-
const USER_STRUCTURE_DIRS = new Set(["memories"]);
178-
const AGENT_STRUCTURE_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]);
179+
const USER_STRUCTURE_DIRS = new Set(["memories", "profile.md", ".abstract.md", ".overview.md"]);
180+
const AGENT_STRUCTURE_DIRS = new Set([
181+
"memories",
182+
"skills",
183+
"instructions",
184+
"workspaces",
185+
".abstract.md",
186+
".overview.md",
187+
]);
179188
const REMOTE_RESOURCE_PREFIXES = ["http://", "https://", "git@", "ssh://", "git://"];
180189

181-
function md5Short(input: string): string {
182-
return createHash("md5").update(input).digest("hex").slice(0, 12);
183-
}
184-
185190
export function isMemoryUri(uri: string): boolean {
186191
return MEMORY_URI_PATTERNS.some((pattern) => pattern.test(uri));
187192
}
@@ -211,25 +216,57 @@ async function cleanupUploadTempPath(path?: string): Promise<void> {
211216
}
212217

213218
export class OpenVikingClient {
214-
private spaceCache = new Map<string, Partial<Record<ScopeName, string>>>();
215219
private identityCache = new Map<string, RuntimeIdentity>();
216220

217221
constructor(
218222
private readonly baseUrl: string,
219223
private readonly apiKey: string,
220224
private readonly defaultAgentId: string,
221225
private readonly timeoutMs: number,
226+
private readonly serverAuthMode: ServerAuthMode = "api_key",
222227
/** When set (or defaulted), sent so ROOT key can access tenant-scoped APIs. */
223228
private readonly accountId: string = "",
224229
private readonly userId: string = "",
225230
/** When set, logs routing for find + session writes (tenant headers + paths; never apiKey). */
226231
private readonly routingDebugLog?: (message: string) => void,
232+
private readonly isolateUserScopeByAgent = false,
233+
private readonly isolateAgentScopeByUser = true,
227234
) {}
228235

229236
getDefaultAgentId(): string {
230237
return this.defaultAgentId;
231238
}
232239

240+
async getResolvedIdentity(agentId?: string): Promise<RuntimeIdentity> {
241+
return this.getRuntimeIdentity(agentId);
242+
}
243+
244+
private resolveTenantHeaders():
245+
| { apiKey?: string; accountId?: string; userId?: string }
246+
{
247+
const apiKey = this.apiKey.trim();
248+
const accountId = this.accountId.trim();
249+
const userId = this.userId.trim();
250+
if (this.serverAuthMode === "trusted") {
251+
return {
252+
...(apiKey ? { apiKey } : {}),
253+
accountId: accountId || "default",
254+
userId: userId || "default",
255+
};
256+
}
257+
if (apiKey) {
258+
return {
259+
apiKey,
260+
...(accountId ? { accountId } : {}),
261+
...(userId ? { userId } : {}),
262+
};
263+
}
264+
return {
265+
accountId: accountId || "default",
266+
userId: userId || "default",
267+
};
268+
}
269+
233270
private async emitRoutingDebug(
234271
label: string,
235272
detail: Record<string, unknown>,
@@ -240,16 +277,17 @@ export class OpenVikingClient {
240277
}
241278
const effectiveAgentId = agentId ?? this.defaultAgentId;
242279
const identity = await this.getRuntimeIdentity(agentId);
280+
const tenantHeaders = this.resolveTenantHeaders();
243281
this.routingDebugLog(
244282
`openviking: ${label} ` +
245283
JSON.stringify({
246284
...detail,
247285
X_OpenViking_Agent: effectiveAgentId,
248-
X_OpenViking_Account: this.accountId.trim() || "default",
249-
X_OpenViking_User: this.userId.trim() || "default",
286+
X_OpenViking_Account: tenantHeaders.accountId ?? null,
287+
X_OpenViking_User: tenantHeaders.userId ?? null,
250288
resolved_user_id: identity.userId,
251289
session_vfs_hint: detail.sessionId
252-
? `viking://session/${identity.userId}/${String(detail.sessionId)}`
290+
? `viking://session/${String(detail.sessionId)}`
253291
: undefined,
254292
}),
255293
);
@@ -266,11 +304,16 @@ export class OpenVikingClient {
266304
const timer = setTimeout(() => controller.abort(), requestTimeoutMs ?? this.timeoutMs);
267305
try {
268306
const headers = new Headers(init.headers ?? {});
269-
if (this.apiKey) {
270-
headers.set("X-API-Key", this.apiKey);
307+
const tenantHeaders = this.resolveTenantHeaders();
308+
if (tenantHeaders.apiKey) {
309+
headers.set("X-API-Key", tenantHeaders.apiKey);
310+
}
311+
if (tenantHeaders.accountId) {
312+
headers.set("X-OpenViking-Account", tenantHeaders.accountId);
313+
}
314+
if (tenantHeaders.userId) {
315+
headers.set("X-OpenViking-User", tenantHeaders.userId);
271316
}
272-
headers.set("X-OpenViking-Account", this.accountId.trim() || "default");
273-
headers.set("X-OpenViking-User", this.userId.trim() || "default");
274317
if (effectiveAgentId) {
275318
headers.set("X-OpenViking-Agent", effectiveAgentId);
276319
}
@@ -306,14 +349,6 @@ export class OpenVikingClient {
306349
await this.request<{ status: string }>("/health");
307350
}
308351

309-
private async ls(uri: string, agentId?: string): Promise<Array<Record<string, unknown>>> {
310-
return this.request<Array<Record<string, unknown>>>(
311-
`/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`,
312-
{},
313-
agentId,
314-
);
315-
}
316-
317352
private async getRuntimeIdentity(agentId?: string): Promise<RuntimeIdentity> {
318353
const effectiveAgentId = agentId ?? this.defaultAgentId;
319354
const cached = this.identityCache.get(effectiveAgentId);
@@ -334,54 +369,18 @@ export class OpenVikingClient {
334369
}
335370
}
336371

337-
private async resolveScopeSpace(scope: ScopeName, agentId?: string): Promise<string> {
338-
const effectiveAgentId = agentId ?? this.defaultAgentId;
339-
const agentScopes = this.spaceCache.get(effectiveAgentId);
340-
const cached = agentScopes?.[scope];
341-
if (cached) {
342-
return cached;
343-
}
344-
372+
private async buildCanonicalRoot(scope: ScopeName, agentId?: string): Promise<string> {
345373
const identity = await this.getRuntimeIdentity(agentId);
346-
const fallbackSpace =
347-
scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
348-
const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
349-
const preferredSpace =
350-
scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
351-
352-
const saveSpace = (space: string) => {
353-
const existing = this.spaceCache.get(effectiveAgentId) ?? {};
354-
existing[scope] = space;
355-
this.spaceCache.set(effectiveAgentId, existing);
356-
};
357-
358-
try {
359-
const entries = await this.ls(`viking://${scope}`, agentId);
360-
const spaces = entries
361-
.filter((entry) => entry?.isDir === true)
362-
.map((entry) => (typeof entry.name === "string" ? entry.name.trim() : ""))
363-
.filter((name) => name && !name.startsWith(".") && !reservedDirs.has(name));
364-
365-
if (spaces.length > 0) {
366-
if (spaces.includes(preferredSpace)) {
367-
saveSpace(preferredSpace);
368-
return preferredSpace;
369-
}
370-
if (scope === "user" && spaces.includes("default")) {
371-
saveSpace("default");
372-
return "default";
373-
}
374-
if (spaces.length === 1) {
375-
saveSpace(spaces[0]!);
376-
return spaces[0]!;
377-
}
378-
}
379-
} catch {
380-
// Fall back to identity-derived space when listing fails.
374+
if (scope === "user") {
375+
const root = this.isolateUserScopeByAgent
376+
? `viking://user/${identity.userId}/agent/${identity.agentId}`
377+
: `viking://user/${identity.userId}`;
378+
return root;
381379
}
382-
383-
saveSpace(fallbackSpace);
384-
return fallbackSpace;
380+
const root = this.isolateAgentScopeByUser
381+
? `viking://agent/${identity.agentId}/user/${identity.userId}`
382+
: `viking://agent/${identity.agentId}`;
383+
return root;
385384
}
386385

387386
private async normalizeTargetUri(targetUri: string, agentId?: string): Promise<string> {
@@ -405,8 +404,8 @@ export class OpenVikingClient {
405404
return trimmed;
406405
}
407406

408-
const space = await this.resolveScopeSpace(scope, agentId);
409-
return `viking://${scope}/${space}/${parts.join("/")}`;
407+
const root = await this.buildCanonicalRoot(scope, agentId);
408+
return `${root}/${parts.join("/")}`;
410409
}
411410

412411
async find(
@@ -427,12 +426,13 @@ export class OpenVikingClient {
427426
};
428427
const effectiveAgentId = agentId ?? this.defaultAgentId;
429428
const identity = await this.getRuntimeIdentity(agentId);
429+
const tenantHeaders = this.resolveTenantHeaders();
430430
this.routingDebugLog?.(
431431
`openviking: find POST ${this.baseUrl}/api/v1/search/find ` +
432432
JSON.stringify({
433433
X_OpenViking_Agent: effectiveAgentId,
434-
X_OpenViking_Account: this.accountId.trim() || "default",
435-
X_OpenViking_User: this.userId.trim() || "default",
434+
X_OpenViking_Account: tenantHeaders.accountId ?? null,
435+
X_OpenViking_User: tenantHeaders.userId ?? null,
436436
resolved_user_id: identity.userId,
437437
target_uri: normalizedTargetUri,
438438
target_uri_input: options.targetUri,
@@ -645,21 +645,27 @@ export class OpenVikingClient {
645645
}>,
646646
agentId?: string,
647647
createdAt?: string,
648+
roleId?: string,
648649
): Promise<void> {
649650
const body: {
650651
role: string;
652+
role_id?: string;
651653
parts: typeof parts;
652654
created_at?: string;
653655
} = { role, parts };
654656
if (createdAt) {
655657
body.created_at = createdAt;
656658
}
659+
if (roleId) {
660+
body.role_id = roleId;
661+
}
657662
await this.emitRoutingDebug(
658663
"session message POST (with parts)",
659664
{
660665
path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
661666
sessionId,
662667
role,
668+
role_id: roleId ?? null,
663669
partCount: parts.length,
664670
created_at: createdAt ?? null,
665671
},

0 commit comments

Comments
 (0)