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
4 changes: 4 additions & 0 deletions examples/openclaw-plugin/INSTALL-AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ openclaw config get plugins.entries.openviking.config
Core OpenClaw plugin fields:

- `mode=local`
- `accountId`
- `userId`
- `configPath`
- `port`
- `agentId`
Expand All @@ -237,6 +239,8 @@ Core OpenClaw plugin fields:
- `mode=remote`
- `baseUrl`
- `apiKey`
- `accountId`
- `userId`
- `agentId`

## Uninstall
Expand Down
6 changes: 6 additions & 0 deletions examples/openclaw-plugin/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand All @@ -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
```

Expand Down
3 changes: 2 additions & 1 deletion examples/openclaw-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<configAgentId>_<sessionAgent>`
- 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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion examples/openclaw-plugin/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- 非安全路径字符会被规整或退化成稳定的 SHA-256。
- `X-OpenViking-Agent` 按 session 解析,不按进程写死。
- 若 `plugins.entries.openviking.config.agentId` 不是 `default`,会形成 `<configAgentId>_<sessionAgent>` 的前缀形式。
- 支持通过 `plugins.entries.openviking.config.accountId` 和 `userId` 显式指定租户路由。
- client 层统一补全 `X-OpenViking-Account`、`X-OpenViking-User`、`X-OpenViking-Agent` 这些 header。

这样做是为了支持多 agent、多 session 并发时的记忆隔离,避免不同 OpenClaw 会话串用同一套长期上下文。
Expand Down Expand Up @@ -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 服务启动起来”,不在上层的上下文模型本身。
Expand Down
5 changes: 4 additions & 1 deletion examples/openclaw-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,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 =
Expand Down
32 changes: 32 additions & 0 deletions examples/openclaw-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -146,6 +164,8 @@ export const memoryOpenVikingConfigSchema = {
"configPath",
"port",
"baseUrl",
"accountId",
"userId",
"agentId",
"apiKey",
"targetUri",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions examples/openclaw-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,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)",
Expand All @@ -540,8 +542,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 <configAgentId>_<ctx.agentId> (sanitized to [a-zA-Z0-9_-]) when hooks expose session agent; config-only if ctx.agentId unknown"
Expand All @@ -553,8 +555,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<OpenVikingClient>;
Expand Down
16 changes: 16 additions & 0 deletions examples/openclaw-plugin/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -152,6 +162,12 @@
"baseUrl": {
"type": "string"
},
"accountId": {
"type": "string"
},
"userId": {
"type": "string"
},
"agentId": {
"type": "string"
},
Expand Down
26 changes: 26 additions & 0 deletions examples/openclaw-plugin/tests/ut/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
25 changes: 25 additions & 0 deletions examples/openclaw-plugin/tests/ut/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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");
Expand Down
32 changes: 32 additions & 0 deletions examples/openclaw-plugin/tests/ut/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down