diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts index 5d2bae044..011b79ab7 100644 --- a/examples/openclaw-plugin/client.ts +++ b/examples/openclaw-plugin/client.ts @@ -23,6 +23,7 @@ export type ScopeName = "user" | "agent"; export type RuntimeIdentity = { userId: string; agentId: string; + serverReportedUserId?: string; }; export type LocalClientCacheEntry = { client: OpenVikingClient; @@ -135,6 +136,7 @@ export function isMemoryUri(uri: string): boolean { export class OpenVikingClient { private spaceCache = new Map>>(); private identityCache = new Map(); + private warnedIdentityMismatches = new Set(); constructor( private readonly baseUrl: string, @@ -146,6 +148,8 @@ export class OpenVikingClient { private readonly userId: string = "", /** When set, logs routing for find + session writes (tenant headers + paths; never apiKey). */ private readonly routingDebugLog?: (message: string) => void, + /** Emits identity mismatch warning regardless of verbose routing debug setting. */ + private readonly identityWarningLog?: (message: string) => void, ) {} getDefaultAgentId(): string { @@ -170,6 +174,7 @@ export class OpenVikingClient { X_OpenViking_Account: this.accountId.trim() || "default", X_OpenViking_User: this.userId.trim() || "default", resolved_user_id: identity.userId, + server_reported_user_id: identity.serverReportedUserId ?? null, session_vfs_hint: detail.sessionId ? `viking://session/${identity.userId}/${String(detail.sessionId)}` : undefined, @@ -177,6 +182,28 @@ export class OpenVikingClient { ); } + private emitIdentityMismatchWarning( + effectiveAgentId: string, + configuredUserId: string, + serverReportedUserId: string, + ): void { + const log = this.identityWarningLog ?? this.routingDebugLog; + if (!log || this.warnedIdentityMismatches.has(effectiveAgentId)) { + return; + } + this.warnedIdentityMismatches.add(effectiveAgentId); + log( + "openviking: WARNING user identity mismatch " + + JSON.stringify({ + X_OpenViking_Agent: effectiveAgentId, + X_OpenViking_Account: this.accountId.trim() || "default", + X_OpenViking_User: this.userId.trim() || "default", + resolved_user_id: configuredUserId, + server_reported_user_id: serverReportedUserId, + }), + ); + } + private async request(path: string, init: RequestInit = {}, agentId?: string): Promise { const effectiveAgentId = agentId ?? this.defaultAgentId; const controller = new AbortController(); @@ -237,13 +264,25 @@ export class OpenVikingClient { if (cached) { return cached; } - const fallback: RuntimeIdentity = { userId: "default", agentId: effectiveAgentId || "default" }; + const configuredUserId = this.userId.trim(); + const fallback: RuntimeIdentity = { + userId: configuredUserId || "default", + agentId: effectiveAgentId || "default", + }; try { const status = await this.request<{ user?: unknown }>("/api/v1/system/status", {}, agentId); - const userId = + const serverReportedUserId = typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default"; - const identity: RuntimeIdentity = { userId, agentId: effectiveAgentId || "default" }; + const userId = configuredUserId || serverReportedUserId; + const identity: RuntimeIdentity = { + userId, + agentId: effectiveAgentId || "default", + serverReportedUserId, + }; this.identityCache.set(effectiveAgentId, identity); + if (configuredUserId && configuredUserId !== serverReportedUserId) { + this.emitIdentityMismatchWarning(effectiveAgentId, configuredUserId, serverReportedUserId); + } return identity; } catch { this.identityCache.set(effectiveAgentId, fallback); @@ -351,6 +390,7 @@ export class OpenVikingClient { X_OpenViking_Account: this.accountId.trim() || "default", X_OpenViking_User: this.userId.trim() || "default", resolved_user_id: identity.userId, + server_reported_user_id: identity.serverReportedUserId ?? null, target_uri: normalizedTargetUri, target_uri_input: options.targetUri, query: diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index be9c4f916..66501058b 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -12,6 +12,8 @@ export type MemoryOpenVikingConfig = { baseUrl?: string; agentId?: string; apiKey?: string; + accountId?: string; + userId?: string; targetUri?: string; timeoutMs?: number; autoCapture?: boolean; @@ -148,6 +150,8 @@ export const memoryOpenVikingConfigSchema = { "baseUrl", "agentId", "apiKey", + "accountId", + "userId", "targetUri", "timeoutMs", "autoCapture", @@ -204,6 +208,8 @@ export const memoryOpenVikingConfigSchema = { baseUrl: resolvedBaseUrl, agentId: resolveAgentId(cfg.agentId), apiKey: rawApiKey ? resolveEnvVars(rawApiKey) : "", + accountId: typeof cfg.accountId === "string" ? resolveEnvVars(cfg.accountId).trim() : "", + userId: typeof cfg.userId === "string" ? resolveEnvVars(cfg.userId).trim() : "", targetUri: typeof cfg.targetUri === "string" ? cfg.targetUri : DEFAULT_TARGET_URI, timeoutMs: Math.max(1000, Math.floor(toNumber(cfg.timeoutMs, DEFAULT_TIMEOUT_MS))), autoCapture: cfg.autoCapture !== false, @@ -304,6 +310,16 @@ export const memoryOpenVikingConfigSchema = { placeholder: "${OPENVIKING_API_KEY}", help: "Optional API key for OpenViking server", }, + accountId: { + label: "Account ID", + placeholder: "acct-prod", + help: "Optional tenant account for X-OpenViking-Account. Required for non-default tenant routing with root_api_key.", + }, + userId: { + label: "User ID", + placeholder: "user-42", + help: "Optional tenant user for X-OpenViking-User. Required for non-default tenant routing with root_api_key.", + }, targetUri: { label: "Search Target URI", placeholder: DEFAULT_TARGET_URI, diff --git a/examples/openclaw-plugin/index.ts b/examples/openclaw-plugin/index.ts index 849501402..d1d41d33b 100644 --- a/examples/openclaw-plugin/index.ts +++ b/examples/openclaw-plugin/index.ts @@ -336,8 +336,11 @@ const contextEnginePlugin = { api.logger.info(msg); } : undefined; - const tenantAccount = ""; - const tenantUser = ""; + const identityWarningLog = (msg: string) => { + api.logger.warn(msg); + }; + const tenantAccount = cfg.accountId; + const tenantUser = cfg.userId; const localCacheKey = `${cfg.mode}:${cfg.baseUrl}:${cfg.configPath}:${cfg.apiKey}:${tenantAccount}:${tenantUser}:${cfg.agentId}:${cfg.logFindRequests ? "1" : "0"}`; let clientPromise: Promise; @@ -394,6 +397,7 @@ const contextEnginePlugin = { tenantAccount, tenantUser, routingDebugLog, + identityWarningLog, ), ); } @@ -1190,6 +1194,7 @@ const contextEnginePlugin = { tenantAccount, tenantUser, routingDebugLog, + identityWarningLog, ); localClientCache.set(localCacheKey, { client, process: child }); resolveLocalClient!(client); @@ -1259,7 +1264,16 @@ const contextEnginePlugin = { }); try { await waitForHealthOrExit(baseUrl, timeoutMs, intervalMs, child); - const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs); + const client = new OpenVikingClient( + baseUrl, + cfg.apiKey, + cfg.agentId, + cfg.timeoutMs, + tenantAccount, + tenantUser, + routingDebugLog, + identityWarningLog, + ); localClientCache.set(localCacheKey, { client, process: child }); if (resolveLocalClient) { resolveLocalClient(client); diff --git a/examples/openclaw-plugin/openclaw.plugin.json b/examples/openclaw-plugin/openclaw.plugin.json index 5b5d1a2c2..5a968b0db 100644 --- a/examples/openclaw-plugin/openclaw.plugin.json +++ b/examples/openclaw-plugin/openclaw.plugin.json @@ -33,6 +33,16 @@ "placeholder": "${OPENVIKING_API_KEY}", "help": "Optional API key for OpenViking server" }, + "accountId": { + "label": "Account ID", + "placeholder": "acct-prod", + "help": "Optional tenant account for X-OpenViking-Account when routing to a non-default tenant." + }, + "userId": { + "label": "User ID", + "placeholder": "user-42", + "help": "Optional tenant user for X-OpenViking-User when routing to a non-default tenant." + }, "targetUri": { "label": "Search Target URI", "placeholder": "viking://user/memories", @@ -155,12 +165,18 @@ "agentId": { "type": "string" }, - "apiKey": { - "type": "string" - }, - "targetUri": { - "type": "string" - }, + "apiKey": { + "type": "string" + }, + "accountId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "targetUri": { + "type": "string" + }, "timeoutMs": { "type": "number" }, diff --git a/examples/openclaw-plugin/tests/ut/config.test.ts b/examples/openclaw-plugin/tests/ut/config.test.ts index 95f2174d3..775e3b0f2 100644 --- a/examples/openclaw-plugin/tests/ut/config.test.ts +++ b/examples/openclaw-plugin/tests/ut/config.test.ts @@ -160,6 +160,43 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { expect(cfg.agentId).toBe("my-agent"); }); + it("accepts tenant account and user ids", () => { + const cfg = memoryOpenVikingConfigSchema.parse({ + accountId: "acct-prod", + userId: "user-42", + }); + expect(cfg.accountId).toBe("acct-prod"); + expect(cfg.userId).toBe("user-42"); + }); + + it("trims tenant account and user ids", () => { + const cfg = memoryOpenVikingConfigSchema.parse({ + accountId: " acct-prod ", + userId: " user-42 ", + }); + expect(cfg.accountId).toBe("acct-prod"); + expect(cfg.userId).toBe("user-42"); + }); + + it("resolves environment variables in tenant account and user ids", () => { + process.env.TEST_OV_ACCOUNT_ID = "acct-env"; + process.env.TEST_OV_USER_ID = "user-env"; + const cfg = memoryOpenVikingConfigSchema.parse({ + accountId: "${TEST_OV_ACCOUNT_ID}", + userId: "${TEST_OV_USER_ID}", + }); + expect(cfg.accountId).toBe("acct-env"); + expect(cfg.userId).toBe("user-env"); + delete process.env.TEST_OV_ACCOUNT_ID; + delete process.env.TEST_OV_USER_ID; + }); + + it("defaults tenant account and user ids to empty strings", () => { + const cfg = memoryOpenVikingConfigSchema.parse({}); + expect(cfg.accountId).toBe(""); + expect(cfg.userId).toBe(""); + }); + it("falls back to 'default' for empty agentId", () => { const cfg = memoryOpenVikingConfigSchema.parse({ agentId: " " }); expect(cfg.agentId).toBe("default"); diff --git a/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts b/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts index 47f2b169d..cb9f451d5 100644 --- a/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts +++ b/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts @@ -8,6 +8,10 @@ import plugin from "../../index.js"; type RequestRecord = { body?: string; + headers: { + account?: string; + user?: string; + }; method: string; path: string; }; @@ -40,9 +44,11 @@ function json(res: ServerResponse, statusCode: number, payload: unknown): void { describe("plugin normal flow with healthy backend", () => { let server: ReturnType; let baseUrl = ""; + let logs: string[] = []; let requests: RequestRecord[] = []; beforeEach(async () => { + logs = []; requests = []; localClientCache.clear(); localClientPendingPromises.clear(); @@ -51,7 +57,15 @@ describe("plugin normal flow with healthy backend", () => { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://127.0.0.1"); const body = method === "POST" ? await readBody(req) : undefined; - requests.push({ body, method, path: `${url.pathname}${url.search}` }); + requests.push({ + body, + headers: { + account: typeof req.headers["x-openviking-account"] === "string" ? req.headers["x-openviking-account"] : undefined, + user: typeof req.headers["x-openviking-user"] === "string" ? req.headers["x-openviking-user"] : undefined, + }, + method, + path: `${url.pathname}${url.search}`, + }); if (method === "GET" && url.pathname === "/health") { json(res, 200, { status: "ok" }); @@ -188,21 +202,23 @@ describe("plugin normal flow with healthy backend", () => { plugin.register({ logger: { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, + debug: (message) => logs.push(message), + error: (message) => logs.push(message), + info: (message) => logs.push(message), + warn: (message) => logs.push(message), }, on: (name, handler) => { handlers.set(name, handler); }, pluginConfig: { + accountId: "acct-prod", autoCapture: true, autoRecall: true, baseUrl, commitTokenThreshold: 20000, ingestReplyAssist: false, mode: "remote", + userId: "user-42", }, registerContextEngine: (_id, factory) => { contextEngineFactory = factory as () => unknown; @@ -270,6 +286,22 @@ describe("plugin normal flow with healthy backend", () => { expect( requests.some((entry) => entry.method === "POST" && entry.path === "/api/v1/search/find"), ).toBe(true); + expect( + requests.some((entry) => entry.method === "GET" && entry.path === "/api/v1/system/status"), + ).toBe(true); + const searchRequest = requests.find( + (entry) => entry.method === "POST" && entry.path === "/api/v1/search/find", + ); + expect(searchRequest?.headers).toEqual({ + account: "acct-prod", + user: "user-42", + }); + expect(JSON.parse(searchRequest?.body ?? "{}")).toMatchObject({ + target_uri: "viking://user/user-42/memories", + }); + const identityMismatchWarning = logs.find((entry) => entry.includes("WARNING user identity mismatch")); + expect(identityMismatchWarning).toContain('"resolved_user_id":"user-42"'); + expect(identityMismatchWarning).toContain('"server_reported_user_id":"default"'); expect( requests.some((entry) => entry.method === "GET" && entry.path.startsWith("/api/v1/sessions/session-normal/context")), ).toBe(true); @@ -280,6 +312,10 @@ describe("plugin normal flow with healthy backend", () => { (entry) => entry.method === "POST" && entry.path === "/api/v1/sessions/session-normal/messages", ); expect(addMessageRequest).toBeTruthy(); + expect(addMessageRequest?.headers).toEqual({ + account: "acct-prod", + user: "user-42", + }); expect(JSON.parse(addMessageRequest!.body ?? "{}")).toMatchObject({ role: "user", created_at: "2026-04-07T08:00:01.000Z",