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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions examples/openclaw-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type ScopeName = "user" | "agent";
export type RuntimeIdentity = {
userId: string;
agentId: string;
serverReportedUserId?: string;
};
export type LocalClientCacheEntry = {
client: OpenVikingClient;
Expand Down Expand Up @@ -135,6 +136,7 @@ export function isMemoryUri(uri: string): boolean {
export class OpenVikingClient {
private spaceCache = new Map<string, Partial<Record<ScopeName, string>>>();
private identityCache = new Map<string, RuntimeIdentity>();
private warnedIdentityMismatches = new Set<string>();

constructor(
private readonly baseUrl: string,
Expand All @@ -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 {
Expand All @@ -170,13 +174,36 @@ 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,
}),
);
}

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<T>(path: string, init: RequestInit = {}, agentId?: string): Promise<T> {
const effectiveAgentId = agentId ?? this.defaultAgentId;
const controller = new AbortController();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions examples/openclaw-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type MemoryOpenVikingConfig = {
baseUrl?: string;
agentId?: string;
apiKey?: string;
accountId?: string;
userId?: string;
targetUri?: string;
timeoutMs?: number;
autoCapture?: boolean;
Expand Down Expand Up @@ -148,6 +150,8 @@ export const memoryOpenVikingConfigSchema = {
"baseUrl",
"agentId",
"apiKey",
"accountId",
"userId",
"targetUri",
"timeoutMs",
"autoCapture",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions examples/openclaw-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenVikingClient>;
Expand Down Expand Up @@ -394,6 +397,7 @@ const contextEnginePlugin = {
tenantAccount,
tenantUser,
routingDebugLog,
identityWarningLog,
),
);
}
Expand Down Expand Up @@ -1190,6 +1194,7 @@ const contextEnginePlugin = {
tenantAccount,
tenantUser,
routingDebugLog,
identityWarningLog,
);
localClientCache.set(localCacheKey, { client, process: child });
resolveLocalClient!(client);
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 22 additions & 6 deletions examples/openclaw-plugin/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
37 changes: 37 additions & 0 deletions examples/openclaw-plugin/tests/ut/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import plugin from "../../index.js";

type RequestRecord = {
body?: string;
headers: {
account?: string;
user?: string;
};
method: string;
path: string;
};
Expand Down Expand Up @@ -40,9 +44,11 @@ function json(res: ServerResponse, statusCode: number, payload: unknown): void {
describe("plugin normal flow with healthy backend", () => {
let server: ReturnType<typeof createServer>;
let baseUrl = "";
let logs: string[] = [];
let requests: RequestRecord[] = [];

beforeEach(async () => {
logs = [];
requests = [];
localClientCache.clear();
localClientPendingPromises.clear();
Expand All @@ -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" });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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",
Expand Down