diff --git a/examples/openclaw-plugin/INSTALL-AGENT.md b/examples/openclaw-plugin/INSTALL-AGENT.md index 66207b61b..bdd734582 100644 --- a/examples/openclaw-plugin/INSTALL-AGENT.md +++ b/examples/openclaw-plugin/INSTALL-AGENT.md @@ -212,6 +212,8 @@ openclaw config get plugins.entries.openviking.config Core OpenClaw plugin fields: - `mode=local` +- `accountId` +- `userId` - `configPath` - `port` - `agentId` @@ -237,6 +239,8 @@ Core OpenClaw plugin fields: - `mode=remote` - `baseUrl` - `apiKey` +- `accountId` +- `userId` - `agentId` ## Uninstall diff --git a/examples/openclaw-plugin/INSTALL.md b/examples/openclaw-plugin/INSTALL.md index 785fb761b..a2d55c0a3 100644 --- a/examples/openclaw-plugin/INSTALL.md +++ b/examples/openclaw-plugin/INSTALL.md @@ -99,6 +99,8 @@ Use this mode when the OpenClaw plugin should start and manage a local OpenVikin | Parameter | Default | Meaning | | --- | --- | --- | | `mode` | `local` | Start a local OpenViking process | +| `accountId` | empty | Optional tenant account header for production routing | +| `userId` | empty | Optional tenant user header for production routing | | `agentId` | `default` | Logical identifier used by this OpenClaw instance in OpenViking | | `configPath` | `~/.openviking/ov.conf` | Path to the local OpenViking config file | | `port` | `1933` | Local OpenViking HTTP port | @@ -128,6 +130,8 @@ Use this mode when you already have a running OpenViking server and want OpenCla | `mode` | `remote` | Connect to an existing OpenViking server | | `baseUrl` | `http://127.0.0.1:1933` | Remote OpenViking HTTP endpoint | | `apiKey` | empty | Optional OpenViking API key | +| `accountId` | empty | Optional tenant account header for root-key production routing | +| `userId` | empty | Optional tenant user header for root-key production routing | | `agentId` | `default` | Logical identifier used by this OpenClaw instance on the remote server | Common remote-mode settings: @@ -136,6 +140,8 @@ Common remote-mode settings: openclaw config set plugins.entries.openviking.config.mode remote openclaw config set plugins.entries.openviking.config.baseUrl http://your-server:1933 openclaw config set plugins.entries.openviking.config.apiKey your-api-key +openclaw config set plugins.entries.openviking.config.accountId your-account-id +openclaw config set plugins.entries.openviking.config.userId your-user-id openclaw config set plugins.entries.openviking.config.agentId your-agent-id ``` diff --git a/examples/openclaw-plugin/README.md b/examples/openclaw-plugin/README.md index c99593a77..ca8ea9a62 100644 --- a/examples/openclaw-plugin/README.md +++ b/examples/openclaw-plugin/README.md @@ -47,6 +47,7 @@ The main rules are: - normalize unsafe path characters, or fall back to a stable SHA-256 when needed - resolve `X-OpenViking-Agent` per session, not per process - when `plugins.entries.openviking.config.agentId` is not `default`, prefix the session agent as `_` +- allow explicit tenant routing through `plugins.entries.openviking.config.accountId` and `userId` - add `X-OpenViking-Account`, `X-OpenViking-User`, and `X-OpenViking-Agent` in the client layer This matters because the plugin is built to support multi-agent and multi-session OpenClaw usage without mixing memories across sessions. @@ -195,7 +196,7 @@ That is why this plugin is not only “memory logic”. It is also a local runti In `remote` mode, the plugin behaves as a pure HTTP client: - no local subprocess is started -- `baseUrl` and optional `apiKey` come from plugin config +- `baseUrl`, optional `apiKey`, and optional tenant headers (`accountId` / `userId`) come from plugin config - session context, memory find/read, commit, and archive expansion behavior stays the same The main difference between `local` and `remote` is who is responsible for bringing up the OpenViking service, not the higher-level context model. diff --git a/examples/openclaw-plugin/README_CN.md b/examples/openclaw-plugin/README_CN.md index ca463ada5..64c993ba0 100644 --- a/examples/openclaw-plugin/README_CN.md +++ b/examples/openclaw-plugin/README_CN.md @@ -47,6 +47,7 @@ - 非安全路径字符会被规整或退化成稳定的 SHA-256。 - `X-OpenViking-Agent` 按 session 解析,不按进程写死。 - 若 `plugins.entries.openviking.config.agentId` 不是 `default`,会形成 `_` 的前缀形式。 +- 支持通过 `plugins.entries.openviking.config.accountId` 和 `userId` 显式指定租户路由。 - client 层统一补全 `X-OpenViking-Account`、`X-OpenViking-User`、`X-OpenViking-Agent` 这些 header。 这样做是为了支持多 agent、多 session 并发时的记忆隔离,避免不同 OpenClaw 会话串用同一套长期上下文。 @@ -195,7 +196,7 @@ Resource 导入支持远程 URL、Git URL、本地文件、本地目录和 zip `remote` 模式下,插件只作为 HTTP 客户端工作: - 不会启动本地子进程 -- `baseUrl` 和可选 `apiKey` 由插件配置提供 +- `baseUrl`、可选 `apiKey`,以及可选的租户 header(`accountId` / `userId`)都由插件配置提供 - session context、memory find/read、commit、archive expand 这些行为保持不变 换句话说,`local` 和 `remote` 的差异主要在“谁负责把 OpenViking 服务启动起来”,不在上层的上下文模型本身。 diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts index c8d4a5796..75b46143b 100644 --- a/examples/openclaw-plugin/client.ts +++ b/examples/openclaw-plugin/client.ts @@ -318,7 +318,10 @@ export class OpenVikingClient { if (cached) { return cached; } - const fallback: RuntimeIdentity = { userId: "default", agentId: effectiveAgentId || "default" }; + const fallback: RuntimeIdentity = { + userId: this.userId.trim() || "default", + agentId: effectiveAgentId || "default", + }; try { const status = await this.request<{ user?: unknown }>("/api/v1/system/status", {}, agentId); const userId = diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index be9c4f916..7d88d45f1 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -10,6 +10,8 @@ export type MemoryOpenVikingConfig = { /** Port for local server when mode is "local". Ignored when mode is "remote". */ port?: number; baseUrl?: string; + accountId?: string; + userId?: string; agentId?: string; apiKey?: string; targetUri?: string; @@ -61,6 +63,22 @@ const DEFAULT_LOCAL_CONFIG_PATH = join(homedir(), ".openviking", "ov.conf"); const DEFAULT_AGENT_ID = "default"; +function resolveOptionalIdentity( + configured: unknown, + envNames: string[], +): string { + if (typeof configured === "string" && configured.trim()) { + return resolveEnvVars(configured.trim()); + } + for (const envName of envNames) { + const envValue = process.env[envName]; + if (typeof envValue === "string" && envValue.trim()) { + return envValue.trim(); + } + } + return ""; +} + function resolveAgentId(configured: unknown): string { if (typeof configured === "string" && configured.trim()) { return configured.trim(); @@ -146,6 +164,8 @@ export const memoryOpenVikingConfigSchema = { "configPath", "port", "baseUrl", + "accountId", + "userId", "agentId", "apiKey", "targetUri", @@ -202,6 +222,8 @@ export const memoryOpenVikingConfigSchema = { configPath, port, baseUrl: resolvedBaseUrl, + accountId: resolveOptionalIdentity(cfg.accountId, ["OPENVIKING_ACCOUNT", "OPENVIKING_ACCOUNT_ID"]), + userId: resolveOptionalIdentity(cfg.userId, ["OPENVIKING_USER", "OPENVIKING_USER_ID"]), agentId: resolveAgentId(cfg.agentId), apiKey: rawApiKey ? resolveEnvVars(rawApiKey) : "", targetUri: typeof cfg.targetUri === "string" ? cfg.targetUri : DEFAULT_TARGET_URI, @@ -293,6 +315,16 @@ export const memoryOpenVikingConfigSchema = { placeholder: DEFAULT_BASE_URL, help: "HTTP URL when mode is remote (or use ${OPENVIKING_BASE_URL})", }, + accountId: { + label: "Account ID", + placeholder: "${OPENVIKING_ACCOUNT}", + help: "Optional OpenViking account header. Needed for tenant-scoped production routing with root_api_key.", + }, + userId: { + label: "User ID", + placeholder: "${OPENVIKING_USER}", + help: "Optional OpenViking user header. Needed for tenant-scoped production routing with root_api_key.", + }, agentId: { label: "Agent ID", placeholder: "auto-generated", diff --git a/examples/openclaw-plugin/index.ts b/examples/openclaw-plugin/index.ts index d128f9ebe..416e4e285 100644 --- a/examples/openclaw-plugin/index.ts +++ b/examples/openclaw-plugin/index.ts @@ -528,6 +528,8 @@ const contextEnginePlugin = { const cfg = memoryOpenVikingConfigSchema.parse(api.pluginConfig); const bypassSessionPatterns = compileSessionPatterns(cfg.bypassSessionPatterns); const rawAgentId = rawCfg.agentId; + const rawAccountId = rawCfg.accountId; + const rawUserId = rawCfg.userId; if (cfg.logFindRequests) { api.logger.info( "openviking: routing debug logging enabled (config logFindRequests, or env OPENVIKING_LOG_ROUTING=1 / OPENVIKING_DEBUG=1)", @@ -539,8 +541,8 @@ const contextEnginePlugin = { } }; verboseRoutingInfo( - `openviking: loaded plugin config agentId="${cfg.agentId}" ` + - `(raw plugins.entries.openviking.config.agentId=${JSON.stringify(rawAgentId ?? "(missing)")}; ` + + `openviking: loaded plugin config accountId="${cfg.accountId || "(default)"}" userId="${cfg.userId || "(default)"}" agentId="${cfg.agentId}" ` + + `(raw accountId=${JSON.stringify(rawAccountId ?? "(missing)")}; raw userId=${JSON.stringify(rawUserId ?? "(missing)")}; raw plugins.entries.openviking.config.agentId=${JSON.stringify(rawAgentId ?? "(missing)")}; ` + `${ cfg.agentId !== "default" ? "non-default → X-OpenViking-Agent is _ (sanitized to [a-zA-Z0-9_-]) when hooks expose session agent; config-only if ctx.agentId unknown" @@ -552,8 +554,8 @@ const contextEnginePlugin = { api.logger.info(msg); } : undefined; - const tenantAccount = ""; - const tenantUser = ""; + 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; diff --git a/examples/openclaw-plugin/openclaw.plugin.json b/examples/openclaw-plugin/openclaw.plugin.json index 5b5d1a2c2..30909f270 100644 --- a/examples/openclaw-plugin/openclaw.plugin.json +++ b/examples/openclaw-plugin/openclaw.plugin.json @@ -22,6 +22,16 @@ "placeholder": "http://127.0.0.1:1933", "help": "HTTP URL when mode is remote (or ${OPENVIKING_BASE_URL})" }, + "accountId": { + "label": "Account ID", + "placeholder": "${OPENVIKING_ACCOUNT}", + "help": "Optional OpenViking account header. Needed for tenant-scoped production routing with root_api_key." + }, + "userId": { + "label": "User ID", + "placeholder": "${OPENVIKING_USER}", + "help": "Optional OpenViking user header. Needed for tenant-scoped production routing with root_api_key." + }, "agentId": { "label": "Agent ID", "placeholder": "random unique ID", @@ -152,6 +162,12 @@ "baseUrl": { "type": "string" }, + "accountId": { + "type": "string" + }, + "userId": { + "type": "string" + }, "agentId": { "type": "string" }, diff --git a/examples/openclaw-plugin/tests/ut/client.test.ts b/examples/openclaw-plugin/tests/ut/client.test.ts index 96f91bd24..0ea2248f8 100644 --- a/examples/openclaw-plugin/tests/ut/client.test.ts +++ b/examples/openclaw-plugin/tests/ut/client.test.ts @@ -72,6 +72,32 @@ describe("isMemoryUri", () => { }); describe("OpenVikingClient resource and skill import", () => { + it("sends configured account and user headers on requests", async () => { + const fetchMock = vi.fn().mockResolvedValue( + okResponse({ root_uri: "viking://resources/site", status: "success" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new OpenVikingClient( + "http://127.0.0.1:1933", + "", + "agent", + 5000, + "acme", + "alice", + ); + await client.addResource({ + pathOrUrl: "https://example.com/docs", + to: "viking://resources/site", + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get("X-OpenViking-Account")).toBe("acme"); + expect(headers.get("X-OpenViking-User")).toBe("alice"); + expect(headers.get("X-OpenViking-Agent")).toBe("agent"); + }); + it("addResource posts remote URL as path", async () => { const fetchMock = vi.fn().mockResolvedValue( okResponse({ root_uri: "viking://resources/site", status: "success" }), diff --git a/examples/openclaw-plugin/tests/ut/config.test.ts b/examples/openclaw-plugin/tests/ut/config.test.ts index 95f2174d3..c28a96caf 100644 --- a/examples/openclaw-plugin/tests/ut/config.test.ts +++ b/examples/openclaw-plugin/tests/ut/config.test.ts @@ -17,6 +17,8 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { expect(cfg.port).toBe(1933); expect(cfg.recallLimit).toBe(6); expect(cfg.recallScoreThreshold).toBe(0.15); + expect(cfg.accountId).toBe(""); + expect(cfg.userId).toBe(""); expect(cfg.autoCapture).toBe(true); expect(cfg.autoRecall).toBe(true); expect(cfg.recallPreferAbstract).toBe(false); @@ -64,6 +66,16 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { delete process.env.TEST_OV_API_KEY; }); + it("resolves accountId and userId from environment defaults", () => { + process.env.OPENVIKING_ACCOUNT = "acme"; + process.env.OPENVIKING_USER = "alice"; + + const cfg = memoryOpenVikingConfigSchema.parse({}); + + expect(cfg.accountId).toBe("acme"); + expect(cfg.userId).toBe("alice"); + }); + it("throws when referenced env var is not set", () => { delete process.env.NOT_SET_OV_VAR; expect(() => @@ -160,6 +172,19 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { expect(cfg.agentId).toBe("my-agent"); }); + it("prefers configured accountId/userId over environment defaults", () => { + process.env.OPENVIKING_ACCOUNT = "env-account"; + process.env.OPENVIKING_USER = "env-user"; + + const cfg = memoryOpenVikingConfigSchema.parse({ + accountId: "cfg-account", + userId: "cfg-user", + }); + + expect(cfg.accountId).toBe("cfg-account"); + expect(cfg.userId).toBe("cfg-user"); + }); + 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/tools.test.ts b/examples/openclaw-plugin/tests/ut/tools.test.ts index fb5edbd96..dd74289a4 100644 --- a/examples/openclaw-plugin/tests/ut/tools.test.ts +++ b/examples/openclaw-plugin/tests/ut/tools.test.ts @@ -564,6 +564,38 @@ describe("Plugin registration", () => { expect(headers.get("X-OpenViking-Agent")).toBe("worker"); }); + it("search command propagates configured account and user headers", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url.endsWith("/api/v1/search/find")) { + return okResponse({ memories: [], resources: [], skills: [], total: 0 }); + } + return okResponse({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const { commands, api } = setupPlugin(); + api.pluginConfig = { + ...api.pluginConfig, + accountId: "acme", + userId: "alice", + }; + contextEnginePlugin.register(api as any); + + await commands.get("ov-search")!.handler({ + args: "test query --uri viking://resources", + commandBody: "/ov-search", + agentId: "worker", + sessionId: "session-1", + sessionKey: "agent:worker:session-1", + }); + + const [, init] = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/api/v1/search/find")) as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get("X-OpenViking-Account")).toBe("acme"); + expect(headers.get("X-OpenViking-User")).toBe("alice"); + expect(headers.get("X-OpenViking-Agent")).toBe("worker"); + }); + it("slash commands honor bypassSessionPatterns", async () => { const fetchMock = vi.fn(async () => okResponse({})); vi.stubGlobal("fetch", fetchMock);