From 306d3c1441573e99d49a9b11c4c22a25fcfe4b4f Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 13:18:10 -0500 Subject: [PATCH 01/18] feat: add Amazon Nova as a model provider Co-Authored-By: Claude Opus 4.6 --- docs/providers/amazon-nova.md | 125 ++++++++++++++++++++ extensions/amazon-nova/index.ts | 52 ++++++++ extensions/amazon-nova/onboard.ts | 33 ++++++ extensions/amazon-nova/openclaw.plugin.json | 27 +++++ extensions/amazon-nova/package.json | 12 ++ extensions/amazon-nova/provider-catalog.ts | 51 ++++++++ 6 files changed, 300 insertions(+) create mode 100644 docs/providers/amazon-nova.md create mode 100644 extensions/amazon-nova/index.ts create mode 100644 extensions/amazon-nova/onboard.ts create mode 100644 extensions/amazon-nova/openclaw.plugin.json create mode 100644 extensions/amazon-nova/package.json create mode 100644 extensions/amazon-nova/provider-catalog.ts diff --git a/docs/providers/amazon-nova.md b/docs/providers/amazon-nova.md new file mode 100644 index 0000000000000..b74d515d06c9e --- /dev/null +++ b/docs/providers/amazon-nova.md @@ -0,0 +1,125 @@ +--- +summary: "Configure Nova models via nova.amazon.com" +read_when: + - You want to use Nova models via the API + - You need to set up NOVA_API_KEY authentication + - You want copy/paste config for Nova +title: "Nova" +--- + +# Nova + +Nova provides frontier AI models with extended thinking capabilities. Configure the +provider and set the default model to `amazon-nova/nova-2-lite-v1`. + +Available models: + +- `nova-2-lite-v1` - 1M context, 65k output, multimodal (text + image), extended thinking +- `nova-2-pro-v1` - 1M context, 65k output, multimodal (text + image), extended thinking + +```bash +openclaw onboard --auth-choice amazon-nova-api-key +``` + +## Usage + +```bash +openclaw agent --model amazon-nova/nova-2-lite-v1 +``` + +## Extended Thinking + +Nova models support extended reasoning. Use the `--thinking` flag: + +```bash +openclaw agent --thinking high +``` + +Or configure it in your model params: + +```json5 +{ + agents: { + defaults: { + model: { primary: "amazon-nova/nova-2-lite-v1" }, + models: { + "amazon-nova/nova-2-lite-v1": { + alias: "Nova 2 Lite", + params: { + reasoning_effort: "high", // "low", "medium", "high" + }, + }, + }, + }, + }, +} +``` + +| Level | Behavior | +| -------- | ---------------------------- | +| `low` | Fast, basic reasoning | +| `medium` | Balanced reasoning and speed | +| `high` | Deep, thorough analysis | + +## Config snippet + +```json5 +{ + env: { NOVA_API_KEY: "your-api-key" }, + agents: { + defaults: { + model: { primary: "amazon-nova/nova-2-lite-v1" }, + models: { + "amazon-nova/nova-2-lite-v1": { alias: "Nova 2 Lite" }, + "amazon-nova/nova-2-pro-v1": { alias: "Nova 2 Pro" }, + }, + }, + }, + models: { + mode: "merge", + providers: { + "amazon-nova": { + baseUrl: "https://api.nova.amazon.com/v1", + apiKey: "${NOVA_API_KEY}", + api: "openai-completions", + headers: { "Accept-Encoding": "identity" }, + models: [ + { + id: "nova-2-lite-v1", + name: "Amazon Nova 2 Lite", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 65535, + compat: { + supportsReasoningEffort: true, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + }, + }, + { + id: "nova-2-pro-v1", + name: "Amazon Nova 2 Pro", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 65535, + compat: { + supportsReasoningEffort: true, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + }, + }, + ], + }, + }, + }, +} +``` + +## Notes + +- The `Accept-Encoding: identity` header is required by the Nova API. +- Model refs use `amazon-nova/` format. diff --git a/extensions/amazon-nova/index.ts b/extensions/amazon-nova/index.ts new file mode 100644 index 0000000000000..0ee8a8ecfa72e --- /dev/null +++ b/extensions/amazon-nova/index.ts @@ -0,0 +1,52 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; +import { applyAmazonNovaConfig, AMAZON_NOVA_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildAmazonNovaProvider } from "./provider-catalog.js"; + +const PROVIDER_ID = "amazon-nova"; + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "Amazon Nova Provider", + description: "Bundled Amazon Nova provider plugin", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Amazon Nova", + docsPath: "/providers/amazon-nova", + envVars: ["NOVA_API_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Amazon Nova API key", + hint: "API key", + optionKey: "novaApiKey", + flagName: "--nova-api-key", + envVar: "NOVA_API_KEY", + promptMessage: "Enter Amazon Nova API key", + defaultModel: AMAZON_NOVA_DEFAULT_MODEL_REF, + expectedProviders: ["amazon-nova"], + applyConfig: (cfg) => applyAmazonNovaConfig(cfg), + wizard: { + choiceId: "amazon-nova-api-key", + choiceLabel: "Amazon Nova API key", + groupId: "amazon-nova", + groupLabel: "Amazon Nova", + groupHint: "API key", + }, + }), + ], + catalog: { + order: "simple", + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildAmazonNovaProvider, + }), + }, + }); + }, +}); diff --git a/extensions/amazon-nova/onboard.ts b/extensions/amazon-nova/onboard.ts new file mode 100644 index 0000000000000..f4a9d0d5b3428 --- /dev/null +++ b/extensions/amazon-nova/onboard.ts @@ -0,0 +1,33 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModels, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; +import { buildAmazonNovaProvider, AMAZON_NOVA_DEFAULT_MODEL_ID } from "./provider-catalog.js"; + +export const AMAZON_NOVA_DEFAULT_MODEL_REF = `amazon-nova/${AMAZON_NOVA_DEFAULT_MODEL_ID}`; + +export function applyAmazonNovaProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[AMAZON_NOVA_DEFAULT_MODEL_REF] = { + ...models[AMAZON_NOVA_DEFAULT_MODEL_REF], + alias: models[AMAZON_NOVA_DEFAULT_MODEL_REF]?.alias ?? "Amazon Nova", + }; + const defaultProvider = buildAmazonNovaProvider(); + const resolvedApi = defaultProvider.api ?? "openai-completions"; + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: models, + providerId: "amazon-nova", + api: resolvedApi, + baseUrl: defaultProvider.baseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: AMAZON_NOVA_DEFAULT_MODEL_ID, + }); +} + +export function applyAmazonNovaConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyAmazonNovaProviderConfig(cfg), + AMAZON_NOVA_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/amazon-nova/openclaw.plugin.json b/extensions/amazon-nova/openclaw.plugin.json new file mode 100644 index 0000000000000..6a5016e7213c4 --- /dev/null +++ b/extensions/amazon-nova/openclaw.plugin.json @@ -0,0 +1,27 @@ +{ + "id": "amazon-nova", + "providers": ["amazon-nova"], + "providerAuthEnvVars": { + "amazon-nova": ["NOVA_API_KEY"] + }, + "providerAuthChoices": [ + { + "provider": "amazon-nova", + "method": "api-key", + "choiceId": "amazon-nova-api-key", + "choiceLabel": "Amazon Nova API key", + "groupId": "amazon-nova", + "groupLabel": "Amazon Nova", + "groupHint": "API key", + "optionKey": "novaApiKey", + "cliFlag": "--nova-api-key", + "cliOption": "--nova-api-key ", + "cliDescription": "Amazon Nova API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/amazon-nova/package.json b/extensions/amazon-nova/package.json new file mode 100644 index 0000000000000..374670e8a3337 --- /dev/null +++ b/extensions/amazon-nova/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/amazon-nova-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Amazon Nova provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/amazon-nova/provider-catalog.ts b/extensions/amazon-nova/provider-catalog.ts new file mode 100644 index 0000000000000..428ad67bd0fc9 --- /dev/null +++ b/extensions/amazon-nova/provider-catalog.ts @@ -0,0 +1,51 @@ +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; + +const AMAZON_NOVA_BASE_URL = "https://api.nova.amazon.com/v1"; +export const AMAZON_NOVA_DEFAULT_MODEL_ID = "nova-2-lite-v1"; +const AMAZON_NOVA_DEFAULT_CONTEXT_WINDOW = 1000000; +const AMAZON_NOVA_DEFAULT_MAX_TOKENS = 65535; +const AMAZON_NOVA_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; +const AMAZON_NOVA_HEADERS = { "Accept-Encoding": "identity" } as const; + +export function buildAmazonNovaProvider(): ModelProviderConfig { + return { + baseUrl: AMAZON_NOVA_BASE_URL, + api: "openai-completions", + headers: AMAZON_NOVA_HEADERS, + models: [ + { + id: "nova-2-lite-v1", + name: "Amazon Nova 2 Lite", + reasoning: true, + input: ["text", "image"], + cost: AMAZON_NOVA_DEFAULT_COST, + contextWindow: AMAZON_NOVA_DEFAULT_CONTEXT_WINDOW, + maxTokens: AMAZON_NOVA_DEFAULT_MAX_TOKENS, + compat: { + supportsReasoningEffort: true, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + }, + }, + { + id: "nova-2-pro-v1", + name: "Amazon Nova 2 Pro", + reasoning: true, + input: ["text", "image"], + cost: AMAZON_NOVA_DEFAULT_COST, + contextWindow: AMAZON_NOVA_DEFAULT_CONTEXT_WINDOW, + maxTokens: AMAZON_NOVA_DEFAULT_MAX_TOKENS, + compat: { + supportsReasoningEffort: true, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + }, + }, + ], + }; +} From 4895b7106f74f2f78d65a2f2e02075f6a05bbb93 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 13:39:41 -0500 Subject: [PATCH 02/18] feat: add Nova as a channel plugin WebSocket-based channel plugin for nova.amazon.com with credential resolution, inbound message parsing, outbound delivery, onboarding wizard, reconnect with exponential backoff, and heartbeat keepalive. Co-Authored-By: Claude Opus 4.6 --- extensions/nova/index.ts | 17 ++ extensions/nova/openclaw.plugin.json | 9 + extensions/nova/package.json | 32 +++ extensions/nova/src/channel.ts | 196 +++++++++++++++++ extensions/nova/src/connection.ts | 11 + extensions/nova/src/credentials.test.ts | 115 ++++++++++ extensions/nova/src/credentials.ts | 19 ++ extensions/nova/src/inbound.test.ts | 142 +++++++++++++ extensions/nova/src/inbound.ts | 35 +++ extensions/nova/src/index.ts | 4 + extensions/nova/src/monitor.ts | 272 ++++++++++++++++++++++++ extensions/nova/src/onboarding.ts | 199 +++++++++++++++++ extensions/nova/src/outbound.ts | 20 ++ extensions/nova/src/probe.test.ts | 67 ++++++ extensions/nova/src/probe.ts | 42 ++++ extensions/nova/src/runtime.ts | 14 ++ extensions/nova/src/send.ts | 45 ++++ extensions/nova/src/types.ts | 45 ++++ 18 files changed, 1284 insertions(+) create mode 100644 extensions/nova/index.ts create mode 100644 extensions/nova/openclaw.plugin.json create mode 100644 extensions/nova/package.json create mode 100644 extensions/nova/src/channel.ts create mode 100644 extensions/nova/src/connection.ts create mode 100644 extensions/nova/src/credentials.test.ts create mode 100644 extensions/nova/src/credentials.ts create mode 100644 extensions/nova/src/inbound.test.ts create mode 100644 extensions/nova/src/inbound.ts create mode 100644 extensions/nova/src/index.ts create mode 100644 extensions/nova/src/monitor.ts create mode 100644 extensions/nova/src/onboarding.ts create mode 100644 extensions/nova/src/outbound.ts create mode 100644 extensions/nova/src/probe.test.ts create mode 100644 extensions/nova/src/probe.ts create mode 100644 extensions/nova/src/runtime.ts create mode 100644 extensions/nova/src/send.ts create mode 100644 extensions/nova/src/types.ts diff --git a/extensions/nova/index.ts b/extensions/nova/index.ts new file mode 100644 index 0000000000000..32adb22051ae5 --- /dev/null +++ b/extensions/nova/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { novaPlugin } from "./src/channel.js"; +import { setNovaRuntime } from "./src/runtime.js"; + +const plugin = { + id: "nova", + name: "Nova", + description: "Nova channel plugin (nova.amazon.com)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setNovaRuntime(api.runtime); + api.registerChannel({ plugin: novaPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/nova/openclaw.plugin.json b/extensions/nova/openclaw.plugin.json new file mode 100644 index 0000000000000..fe7c507367f31 --- /dev/null +++ b/extensions/nova/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "nova", + "channels": ["nova"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/nova/package.json b/extensions/nova/package.json new file mode 100644 index 0000000000000..ea70a0d4433da --- /dev/null +++ b/extensions/nova/package.json @@ -0,0 +1,32 @@ +{ + "name": "@openclaw/nova", + "version": "2026.2.1", + "description": "OpenClaw Nova channel plugin (nova.amazon.com)", + "type": "module", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.14", + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "nova", + "label": "Nova", + "selectionLabel": "Nova (WebSocket)", + "docsPath": "/channels/nova", + "docsLabel": "nova", + "blurb": "nova.amazon.com via WebSocket.", + "order": 80 + }, + "install": { + "npmSpec": "@openclaw/nova", + "localPath": "extensions/nova", + "defaultChoice": "local" + } + } +} diff --git a/extensions/nova/src/channel.ts b/extensions/nova/src/channel.ts new file mode 100644 index 0000000000000..eaf9b2acd2489 --- /dev/null +++ b/extensions/nova/src/channel.ts @@ -0,0 +1,196 @@ +import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import type { NovaConfig } from "./types.js"; +import { resolveNovaCredentials } from "./credentials.js"; +import { novaOnboardingAdapter } from "./onboarding.js"; +import { novaOutbound } from "./outbound.js"; +import { probeNova } from "./probe.js"; +import { sendNovaMessage } from "./send.js"; + +type ResolvedNovaAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + +const meta = { + id: "nova", + label: "Nova", + selectionLabel: "Nova (WebSocket)", + docsPath: "/channels/nova", + docsLabel: "nova", + blurb: "nova.amazon.com via WebSocket.", + order: 80, +} as const; + +export const novaPlugin: ChannelPlugin = { + id: "nova", + meta: { ...meta }, + onboarding: novaOnboardingAdapter, + pairing: { + idLabel: "novaUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(nova|user):/i, ""), + notifyApproval: async ({ cfg, id }) => { + sendNovaMessage({ + cfg, + to: id, + text: "Your pairing request has been approved.", + done: true, + }); + }, + }, + capabilities: { + chatTypes: ["direct"], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 50, idleMs: 200 }, + }, + reload: { configPrefixes: ["channels.nova"] }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.channels?.nova?.enabled !== false, + configured: Boolean(resolveNovaCredentials(cfg.channels?.nova as NovaConfig | undefined)), + }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + channels: { + ...cfg.channels, + nova: { + ...cfg.channels?.nova, + enabled, + }, + }, + }), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as OpenClawConfig; + const nextChannels = { ...cfg.channels }; + delete nextChannels.nova; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => + Boolean(resolveNovaCredentials(cfg.channels?.nova as NovaConfig | undefined)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => cfg.channels?.nova?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + collectWarnings: ({ cfg }) => { + const novaCfg = cfg.channels?.nova as NovaConfig | undefined; + const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; + if (dmPolicy === "open") { + return [ + `- Nova: dmPolicy="open" allows any Nova user to send messages. Set channels.nova.dmPolicy="allowlist" + channels.nova.allowFrom to restrict senders.`, + ]; + } + return []; + }, + }, + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + nova: { + ...cfg.channels?.nova, + enabled: true, + }, + }, + }), + }, + messaging: { + normalizeTarget: (raw) => { + const trimmed = raw.trim().replace(/^nova:/i, ""); + return trimmed || null; + }, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^nova:/i.test(trimmed)) { + return true; + } + // Nova user IDs are opaque strings; accept anything non-empty + return trimmed.length > 0; + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, query, limit }) => { + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + for (const entry of cfg.channels?.nova?.allowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") { + ids.add(trimmed); + } + } + return Array.from(ids) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + }, + listGroups: async () => [], + }, + outbound: novaOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ cfg }) => probeNova(cfg.channels?.nova as NovaConfig | undefined), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { monitorNovaProvider } = await import("./monitor.js"); + ctx.log?.info("starting Nova WebSocket provider"); + return monitorNovaProvider({ + cfg: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/extensions/nova/src/connection.ts b/extensions/nova/src/connection.ts new file mode 100644 index 0000000000000..169e4baf03932 --- /dev/null +++ b/extensions/nova/src/connection.ts @@ -0,0 +1,11 @@ +import type WebSocket from "ws"; + +let activeConnection: WebSocket | null = null; + +export function setActiveNovaConnection(ws: WebSocket | null): void { + activeConnection = ws; +} + +export function getActiveNovaConnection(): WebSocket | null { + return activeConnection; +} diff --git a/extensions/nova/src/credentials.test.ts b/extensions/nova/src/credentials.test.ts new file mode 100644 index 0000000000000..0c65d08395570 --- /dev/null +++ b/extensions/nova/src/credentials.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { NovaConfig } from "./types.js"; +import { resolveNovaCredentials } from "./credentials.js"; + +describe("resolveNovaCredentials", () => { + const savedEnv: Record = {}; + + beforeEach(() => { + savedEnv.NOVA_BASE_URL = process.env.NOVA_BASE_URL; + savedEnv.NOVA_API_KEY = process.env.NOVA_API_KEY; + savedEnv.NOVA_USER_ID = process.env.NOVA_USER_ID; + delete process.env.NOVA_BASE_URL; + delete process.env.NOVA_API_KEY; + delete process.env.NOVA_USER_ID; + }); + + afterEach(() => { + process.env.NOVA_BASE_URL = savedEnv.NOVA_BASE_URL; + process.env.NOVA_API_KEY = savedEnv.NOVA_API_KEY; + process.env.NOVA_USER_ID = savedEnv.NOVA_USER_ID; + }); + + it("resolves credentials from config", () => { + const cfg: NovaConfig = { + baseUrl: "wss://custom.example.com", + apiKey: "key-123", + userId: "user-001", + }; + expect(resolveNovaCredentials(cfg)).toEqual({ + baseUrl: "wss://custom.example.com", + apiKey: "key-123", + userId: "user-001", + }); + }); + + it("falls back to env vars when config is empty", () => { + process.env.NOVA_BASE_URL = "wss://env.example.com"; + process.env.NOVA_API_KEY = "env-key"; + process.env.NOVA_USER_ID = "env-user"; + expect(resolveNovaCredentials({})).toEqual({ + baseUrl: "wss://env.example.com", + apiKey: "env-key", + userId: "env-user", + }); + }); + + it("prefers config over env vars", () => { + process.env.NOVA_BASE_URL = "wss://env.example.com"; + process.env.NOVA_API_KEY = "env-key"; + process.env.NOVA_USER_ID = "env-user"; + const cfg: NovaConfig = { + baseUrl: "wss://config.example.com", + apiKey: "cfg-key", + userId: "cfg-user", + }; + expect(resolveNovaCredentials(cfg)).toEqual({ + baseUrl: "wss://config.example.com", + apiKey: "cfg-key", + userId: "cfg-user", + }); + }); + + it("uses default baseUrl when not specified", () => { + const cfg: NovaConfig = { + apiKey: "key", + userId: "user", + }; + const result = resolveNovaCredentials(cfg); + expect(result).toBeDefined(); + expect(result?.baseUrl).toBe("wss://ws.nova-claw.agi.amazon.dev"); + expect(result?.apiKey).toBe("key"); + expect(result?.userId).toBe("user"); + }); + + it("returns undefined when apiKey is missing", () => { + const cfg: NovaConfig = { + userId: "user", + }; + expect(resolveNovaCredentials(cfg)).toBeUndefined(); + }); + + it("returns undefined when userId is missing", () => { + const cfg: NovaConfig = { + apiKey: "key", + }; + expect(resolveNovaCredentials(cfg)).toBeUndefined(); + }); + + it("returns undefined for undefined config", () => { + expect(resolveNovaCredentials(undefined)).toBeUndefined(); + }); + + it("trims whitespace from values", () => { + const cfg: NovaConfig = { + baseUrl: " wss://example.com ", + apiKey: " key ", + userId: " user ", + }; + expect(resolveNovaCredentials(cfg)).toEqual({ + baseUrl: "wss://example.com", + apiKey: "key", + userId: "user", + }); + }); + + it("uses default baseUrl for whitespace-only baseUrl", () => { + const cfg: NovaConfig = { + baseUrl: " ", + apiKey: "key", + userId: "user", + }; + const result = resolveNovaCredentials(cfg); + expect(result?.baseUrl).toBe("wss://ws.nova-claw.agi.amazon.dev"); + }); +}); diff --git a/extensions/nova/src/credentials.ts b/extensions/nova/src/credentials.ts new file mode 100644 index 0000000000000..b17db129fd3df --- /dev/null +++ b/extensions/nova/src/credentials.ts @@ -0,0 +1,19 @@ +import type { NovaConfig, NovaCredentials } from "./types.js"; + +const DEFAULT_BASE_URL = "wss://ws.nova-claw.agi.amazon.dev"; + +/** + * Resolve Nova credentials from config with env var fallbacks. + * Returns `undefined` when any required field is missing. + */ +export function resolveNovaCredentials(cfg?: NovaConfig): NovaCredentials | undefined { + const baseUrl = cfg?.baseUrl?.trim() || process.env.NOVA_BASE_URL?.trim() || DEFAULT_BASE_URL; + const apiKey = cfg?.apiKey?.trim() || process.env.NOVA_API_KEY?.trim(); + const userId = cfg?.userId?.trim() || process.env.NOVA_USER_ID?.trim(); + + if (!apiKey || !userId) { + return undefined; + } + + return { baseUrl, apiKey, userId }; +} diff --git a/extensions/nova/src/inbound.test.ts b/extensions/nova/src/inbound.test.ts new file mode 100644 index 0000000000000..47d721d21d52c --- /dev/null +++ b/extensions/nova/src/inbound.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { parseNovaInboundMessage } from "./inbound.js"; + +describe("parseNovaInboundMessage", () => { + it("parses a valid message", () => { + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: "Hello, world!", + messageId: "msg-001", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toEqual({ + action: "message", + userId: "user-42", + text: "Hello, world!", + messageId: "msg-001", + timestamp: 1707500000000, + }); + }); + + it("returns null for invalid JSON", () => { + expect(parseNovaInboundMessage("not json")).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseNovaInboundMessage('"hello"')).toBeNull(); + expect(parseNovaInboundMessage("42")).toBeNull(); + expect(parseNovaInboundMessage("null")).toBeNull(); + }); + + it("returns null when action is not 'message'", () => { + const raw = JSON.stringify({ + action: "pong", + userId: "user-42", + text: "Hello", + messageId: "msg-001", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toBeNull(); + }); + + it("returns null when userId is missing", () => { + const raw = JSON.stringify({ + action: "message", + text: "Hello", + messageId: "msg-001", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toBeNull(); + }); + + it("returns null when userId is empty", () => { + const raw = JSON.stringify({ + action: "message", + userId: " ", + text: "Hello", + messageId: "msg-001", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toBeNull(); + }); + + it("returns null when messageId is missing", () => { + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: "Hello", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toBeNull(); + }); + + it("returns null when messageId is empty", () => { + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: "Hello", + messageId: "", + timestamp: 1707500000000, + }); + expect(parseNovaInboundMessage(raw)).toBeNull(); + }); + + it("accepts empty text", () => { + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: "", + messageId: "msg-001", + timestamp: 1707500000000, + }); + const result = parseNovaInboundMessage(raw); + expect(result).not.toBeNull(); + expect(result?.text).toBe(""); + }); + + it("defaults timestamp to Date.now() when missing", () => { + const before = Date.now(); + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: "Hello", + messageId: "msg-001", + }); + const result = parseNovaInboundMessage(raw); + const after = Date.now(); + expect(result).not.toBeNull(); + expect(result!.timestamp).toBeGreaterThanOrEqual(before); + expect(result!.timestamp).toBeLessThanOrEqual(after); + }); + + it("handles non-string text gracefully", () => { + const raw = JSON.stringify({ + action: "message", + userId: "user-42", + text: 123, + messageId: "msg-001", + timestamp: 1707500000000, + }); + const result = parseNovaInboundMessage(raw); + expect(result).not.toBeNull(); + expect(result?.text).toBe(""); + }); + + it("trims userId and messageId", () => { + const raw = JSON.stringify({ + action: "message", + userId: " user-42 ", + text: "Hello", + messageId: " msg-001 ", + timestamp: 1707500000000, + }); + const result = parseNovaInboundMessage(raw); + expect(result?.userId).toBe("user-42"); + expect(result?.messageId).toBe("msg-001"); + }); + + it("returns null for array input", () => { + expect(parseNovaInboundMessage("[]")).toBeNull(); + }); +}); diff --git a/extensions/nova/src/inbound.ts b/extensions/nova/src/inbound.ts new file mode 100644 index 0000000000000..65e219d2324c6 --- /dev/null +++ b/extensions/nova/src/inbound.ts @@ -0,0 +1,35 @@ +import type { NovaInboundMessage } from "./types.js"; + +/** + * Parse an incoming WebSocket message from the API GW Lambda. + * Returns a typed message on success, or `null` for malformed/unrecognized frames. + */ +export function parseNovaInboundMessage(raw: string): NovaInboundMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object") { + return null; + } + + const obj = parsed as Record; + + if (obj.action !== "message") { + return null; + } + + const userId = typeof obj.userId === "string" ? obj.userId.trim() : ""; + const text = typeof obj.text === "string" ? obj.text : ""; + const messageId = typeof obj.messageId === "string" ? obj.messageId.trim() : ""; + const timestamp = typeof obj.timestamp === "number" ? obj.timestamp : Date.now(); + + if (!userId || !messageId) { + return null; + } + + return { action: "message", userId, text, messageId, timestamp }; +} diff --git a/extensions/nova/src/index.ts b/extensions/nova/src/index.ts new file mode 100644 index 0000000000000..a9b54fe0fa034 --- /dev/null +++ b/extensions/nova/src/index.ts @@ -0,0 +1,4 @@ +export { monitorNovaProvider } from "./monitor.js"; +export { probeNova } from "./probe.js"; +export { sendNovaMessage } from "./send.js"; +export { type NovaCredentials, resolveNovaCredentials } from "./credentials.js"; diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts new file mode 100644 index 0000000000000..1d2d06f6e8330 --- /dev/null +++ b/extensions/nova/src/monitor.ts @@ -0,0 +1,272 @@ +import { format } from "node:util"; +import { + createReplyPrefixContext, + DEFAULT_ACCOUNT_ID, + type OpenClawConfig, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import WebSocket from "ws"; +import type { NovaConfig } from "./types.js"; +import { setActiveNovaConnection } from "./connection.js"; +import { resolveNovaCredentials } from "./credentials.js"; +import { parseNovaInboundMessage } from "./inbound.js"; +import { getNovaRuntime } from "./runtime.js"; +import { sendNovaMessage } from "./send.js"; + +export type MonitorNovaOpts = { + cfg: OpenClawConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; +}; + +const DEFAULT_RECONNECT_BASE_MS = 1000; +const MAX_RECONNECT_MS = 60_000; +const DEFAULT_HEARTBEAT_MS = 30_000; + +/** + * Persistent WebSocket client that connects to the Nova backend, + * receives inbound messages, dispatches them through the agent pipeline, + * and reconnects with exponential backoff on failure. + */ +export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise { + const core = getNovaRuntime(); + const cfg = opts.cfg; + const novaCfg = cfg.channels?.nova as NovaConfig | undefined; + + if (novaCfg?.enabled === false) { + return; + } + + const creds = resolveNovaCredentials(novaCfg); + if (!creds) { + throw new Error("Nova credentials not configured (apiKey, userId)"); + } + + const logger = core.logging.getChildLogger({ module: "nova-monitor" }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => logger.info(formatRuntimeMessage(...args)), + error: (...args) => logger.error(formatRuntimeMessage(...args)), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const reconnectBaseMs = novaCfg?.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_MS; + const heartbeatIntervalMs = novaCfg?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS; + const _textLimit = core.channel.text.resolveTextChunkLimit(cfg, "nova"); + + let attempt = 0; + + await new Promise((resolveMonitor) => { + if (opts.abortSignal?.aborted) { + resolveMonitor(); + return; + } + + const onAbort = () => { + logger.info("nova: abort signal received, closing connection"); + const ws = activeWs; + activeWs = null; + setActiveNovaConnection(null); + if (ws && ws.readyState !== WebSocket.CLOSED) { + ws.close(1000, "shutdown"); + } + resolveMonitor(); + }; + + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + + let activeWs: WebSocket | null = null; + let heartbeatTimer: ReturnType | null = null; + + function clearHeartbeat() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + function startHeartbeat(ws: WebSocket) { + clearHeartbeat(); + heartbeatTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: "ping", timestamp: Date.now() })); + } + }, heartbeatIntervalMs); + } + + function scheduleReconnect() { + if (opts.abortSignal?.aborted) { + resolveMonitor(); + return; + } + const jitter = Math.random() * 0.3 + 0.85; // 0.85..1.15 + const delay = Math.min(reconnectBaseMs * 2 ** attempt * jitter, MAX_RECONNECT_MS); + attempt++; + logger.info(`nova: reconnecting in ${Math.round(delay)}ms (attempt ${attempt})`); + setTimeout(connect, delay); + } + + function connect() { + if (opts.abortSignal?.aborted) { + resolveMonitor(); + return; + } + + const url = `${creds.baseUrl}?userId=${encodeURIComponent(creds.userId)}`; + logger.info(`nova: connecting to ${creds.baseUrl}`); + + const ws = new WebSocket(url, { + headers: { Authorization: `Bearer ${creds.apiKey}` }, + }); + activeWs = ws; + + ws.on("open", () => { + logger.info("nova: WebSocket connected"); + setActiveNovaConnection(ws); + attempt = 0; + startHeartbeat(ws); + }); + + ws.on("message", (data: WebSocket.RawData) => { + const raw = typeof data === "string" ? data : Buffer.from(data as Buffer).toString("utf8"); + handleInboundMessage(raw, cfg, runtime).catch((err) => { + runtime.error?.(`nova: dispatch error: ${String(err)}`); + }); + }); + + ws.on("close", (code, reason) => { + logger.info(`nova: WebSocket closed (code=${code}, reason=${reason.toString("utf8")})`); + clearHeartbeat(); + setActiveNovaConnection(null); + activeWs = null; + scheduleReconnect(); + }); + + ws.on("error", (err) => { + logger.error(`nova: WebSocket error: ${String(err)}`); + // 'close' event will follow; reconnect handled there + }); + } + + async function handleInboundMessage( + raw: string, + msgCfg: OpenClawConfig, + msgRuntime: RuntimeEnv, + ): Promise { + const msg = parseNovaInboundMessage(raw); + if (!msg) { + // Ignore non-message frames (pong, ack, etc.) + return; + } + + const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; + const allowFrom = (novaCfg?.allowFrom ?? []).map((entry) => + String(entry).trim().toLowerCase(), + ); + + // Enforce allowlist policy + if (dmPolicy === "allowlist" && allowFrom.length > 0 && !allowFrom.includes("*")) { + const senderId = msg.userId.trim().toLowerCase(); + if (!allowFrom.includes(senderId)) { + logger.info(`nova: message from ${msg.userId} dropped (not in allowlist)`); + return; + } + } + + const novaFrom = `nova:${msg.userId}`; + const novaTo = `nova:${creds.userId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg: msgCfg, + channel: "nova", + accountId: DEFAULT_ACCOUNT_ID, + peer: { kind: "user", id: msg.userId }, + }); + + const storePath = core.config.resolveStorePath(route.agentId); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: msg.text, + RawBody: msg.text, + CommandBody: msg.text, + From: novaFrom, + To: novaTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: "direct" as const, + ConversationLabel: novaFrom, + SenderName: msg.userId, + SenderId: msg.userId, + Provider: "nova" as const, + Surface: "nova" as const, + MessageSid: msg.messageId, + Timestamp: msg.timestamp, + WasMentioned: true, // DMs are always "mentioned" + CommandAuthorized: true, + OriginatingChannel: "nova" as const, + OriginatingTo: novaTo, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + logger.debug(`nova: failed updating session meta: ${String(err)}`); + }, + }); + + logger.info(`nova inbound: from=${msg.userId} preview="${msg.text.slice(0, 60)}"`); + + const prefixContext = createReplyPrefixContext({ + cfg: msgCfg, + agentId: route.agentId, + }); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(msgCfg, route.agentId), + deliver: async (payload) => { + const chunks = payload.parts ?? []; + const fullText = chunks + .map((part) => (typeof part === "string" ? part : (part.text ?? ""))) + .join(""); + if (!fullText.trim()) { + return; + } + sendNovaMessage({ + cfg: msgCfg, + to: msg.userId, + text: fullText, + replyTo: msg.messageId, + done: true, + }); + }, + onError: (err, info) => { + msgRuntime.error?.(`nova ${info.kind} reply failed: ${String(err)}`); + }, + }); + + try { + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg: msgCfg, + dispatcher, + replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, + }); + markDispatchIdle(); + logger.info(`nova: dispatch complete (queuedFinal=${queuedFinal}, final=${counts.final})`); + } catch (err) { + logger.error(`nova: dispatch failed: ${String(err)}`); + msgRuntime.error?.(`nova dispatch failed: ${String(err)}`); + } + } + + // Start the first connection attempt + connect(); + }); +} diff --git a/extensions/nova/src/onboarding.ts b/extensions/nova/src/onboarding.ts new file mode 100644 index 0000000000000..1298b03785663 --- /dev/null +++ b/extensions/nova/src/onboarding.ts @@ -0,0 +1,199 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + OpenClawConfig, + DmPolicy, +} from "openclaw/plugin-sdk"; +import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import { resolveNovaCredentials } from "./credentials.js"; + +const channel = "nova" as const; + +function setNovaDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.channels?.nova?.allowFrom)?.map((entry) => String(entry)) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + nova: { + ...cfg.channels?.nova, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setNovaAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + nova: { + ...cfg.channels?.nova, + allowFrom, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Nova", + channel, + policyKey: "channels.nova.dmPolicy", + allowFromKey: "channels.nova.allowFrom", + getCurrent: (cfg) => cfg.channels?.nova?.dmPolicy ?? "allowlist", + setPolicy: (cfg, policy) => setNovaDmPolicy(cfg, policy), + promptAllowFrom: async ({ cfg, prompter }) => { + const existing = cfg.channels?.nova?.allowFrom ?? []; + const entry = await prompter.text({ + message: "Nova allowFrom (user ids, comma-separated)", + placeholder: "nova-user-id-1, nova-user-id-2", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = String(entry) + .split(/[\n,;]+/g) + .map((s) => s.trim()) + .filter(Boolean); + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]), + ]; + return setNovaAllowFrom(cfg, unique); + }, +}; + +export const novaOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = Boolean(resolveNovaCredentials(cfg.channels?.nova)); + return { + channel, + configured, + statusLines: [`Nova: ${configured ? "configured" : "needs credentials"}`], + selectionHint: configured ? "configured" : "needs credentials", + quickstartScore: configured ? 2 : 0, + }; + }, + configure: async ({ cfg, prompter }) => { + const resolved = resolveNovaCredentials(cfg.channels?.nova); + let next = cfg; + let apiKey: string | null = null; + let userId: string | null = null; + let baseUrl: string | null = null; + + const hasConfigCreds = Boolean( + cfg.channels?.nova?.apiKey?.trim() && cfg.channels?.nova?.userId?.trim(), + ); + const canUseEnv = Boolean( + !hasConfigCreds && process.env.NOVA_API_KEY?.trim() && process.env.NOVA_USER_ID?.trim(), + ); + + if (!resolved) { + await prompter.note( + [ + "Configure Nova channel.", + "You need:", + "- API key (Bearer token)", + "- User ID (your Nova user identity)", + "- Base URL (optional, defaults to wss://ws.nova-claw.agi.amazon.dev)", + "Tip: set NOVA_API_KEY / NOVA_USER_ID env vars.", + ].join("\n"), + "Nova credentials", + ); + } + + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: "NOVA_API_KEY + NOVA_USER_ID detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + nova: { ...next.channels?.nova, enabled: true }, + }, + }; + } else { + apiKey = await promptApiKey(prompter); + userId = await promptUserId(prompter); + baseUrl = await promptBaseUrl(prompter); + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "Nova credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + apiKey = await promptApiKey(prompter); + userId = await promptUserId(prompter); + baseUrl = await promptBaseUrl(prompter); + } + } else { + apiKey = await promptApiKey(prompter); + userId = await promptUserId(prompter); + baseUrl = await promptBaseUrl(prompter); + } + + if (apiKey && userId) { + next = { + ...next, + channels: { + ...next.channels, + nova: { + ...next.channels?.nova, + enabled: true, + ...(baseUrl ? { baseUrl } : {}), + apiKey, + userId, + }, + }, + }; + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + nova: { ...cfg.channels?.nova, enabled: false }, + }, + }), +}; + +type Prompter = Parameters[0]["prompter"]; + +async function promptApiKey(prompter: Prompter): Promise { + return String( + await prompter.text({ + message: "Nova API key", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); +} + +async function promptUserId(prompter: Prompter): Promise { + return String( + await prompter.text({ + message: "Nova user ID", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); +} + +async function promptBaseUrl(prompter: Prompter): Promise { + const value = String( + await prompter.text({ + message: "Base URL (leave empty for default)", + placeholder: "wss://ws.nova-claw.agi.amazon.dev", + }), + ).trim(); + return value || null; +} diff --git a/extensions/nova/src/outbound.ts b/extensions/nova/src/outbound.ts new file mode 100644 index 0000000000000..6333e5e155ad2 --- /dev/null +++ b/extensions/nova/src/outbound.ts @@ -0,0 +1,20 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import { getNovaRuntime } from "./runtime.js"; +import { sendNovaMessage } from "./send.js"; + +export const novaOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getNovaRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text }) => { + const result = sendNovaMessage({ cfg, to, text, done: true }); + return { channel: "nova", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl }) => { + // Send text with media URL inline (no native media embedding yet) + const body = mediaUrl ? `${text}\n${mediaUrl}` : text; + const result = sendNovaMessage({ cfg, to, text: body, done: true }); + return { channel: "nova", ...result }; + }, +}; diff --git a/extensions/nova/src/probe.test.ts b/extensions/nova/src/probe.test.ts new file mode 100644 index 0000000000000..c989d5b298210 --- /dev/null +++ b/extensions/nova/src/probe.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { NovaConfig } from "./types.js"; +import { probeNova } from "./probe.js"; + +describe("probeNova", () => { + it("returns ok for valid config with default baseUrl", () => { + const cfg: NovaConfig = { + apiKey: "key-123", + userId: "user-001", + }; + expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); + }); + + it("returns ok for valid config with custom baseUrl", () => { + const cfg: NovaConfig = { + baseUrl: "wss://custom.example.com", + apiKey: "key-123", + userId: "user-001", + }; + expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); + }); + + it("returns error when credentials are missing", () => { + expect(probeNova({ enabled: true })).toMatchObject({ + ok: false, + error: expect.stringContaining("missing credentials"), + }); + }); + + it("returns error when credentials are undefined", () => { + expect(probeNova(undefined)).toMatchObject({ + ok: false, + error: expect.stringContaining("missing credentials"), + }); + }); + + it("returns error for non-wss baseUrl", () => { + const cfg: NovaConfig = { + baseUrl: "https://example.com/prod", + apiKey: "key-123", + userId: "user-001", + }; + const result = probeNova(cfg); + expect(result.ok).toBe(false); + expect(result.error).toContain("wss://"); + }); + + it("returns error for invalid baseUrl", () => { + const cfg: NovaConfig = { + baseUrl: "not-a-url", + apiKey: "key-123", + userId: "user-001", + }; + const result = probeNova(cfg); + expect(result.ok).toBe(false); + expect(result.error).toContain("not a valid URL"); + }); + + it("accepts ws:// protocol", () => { + const cfg: NovaConfig = { + baseUrl: "ws://localhost:8080/dev", + apiKey: "key-123", + userId: "user-001", + }; + expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); + }); +}); diff --git a/extensions/nova/src/probe.ts b/extensions/nova/src/probe.ts new file mode 100644 index 0000000000000..981ebb2390ec9 --- /dev/null +++ b/extensions/nova/src/probe.ts @@ -0,0 +1,42 @@ +import type { NovaConfig } from "./types.js"; +import { resolveNovaCredentials } from "./credentials.js"; + +export type ProbeNovaResult = { + ok: boolean; + error?: string; + userId?: string; +}; + +/** + * Verify Nova credentials are present and the base URL is valid. + * Does not attempt an actual WS connection (that happens in the monitor). + */ +export function probeNova(cfg?: NovaConfig): ProbeNovaResult { + const creds = resolveNovaCredentials(cfg); + if (!creds) { + return { + ok: false, + error: "missing credentials (apiKey, userId)", + }; + } + + // Validate URL format + try { + const url = new URL(creds.baseUrl); + if (url.protocol !== "wss:" && url.protocol !== "ws:") { + return { + ok: false, + error: `baseUrl must use wss:// protocol (got ${url.protocol})`, + userId: creds.userId, + }; + } + } catch { + return { + ok: false, + error: "baseUrl is not a valid URL", + userId: creds.userId, + }; + } + + return { ok: true, userId: creds.userId }; +} diff --git a/extensions/nova/src/runtime.ts b/extensions/nova/src/runtime.ts new file mode 100644 index 0000000000000..64045120aff9b --- /dev/null +++ b/extensions/nova/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setNovaRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getNovaRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Nova runtime not initialized"); + } + return runtime; +} diff --git a/extensions/nova/src/send.ts b/extensions/nova/src/send.ts new file mode 100644 index 0000000000000..ec7597540e994 --- /dev/null +++ b/extensions/nova/src/send.ts @@ -0,0 +1,45 @@ +import type { NovaConfig } from "./types.js"; +import { getActiveNovaConnection } from "./connection.js"; +import { resolveNovaCredentials } from "./credentials.js"; + +export type SendNovaMessageOpts = { + cfg: { channels?: { nova?: NovaConfig } }; + to: string; + text: string; + replyTo?: string; + done?: boolean; +}; + +export type SendNovaMessageResult = { + messageId: string; + conversationId: string; +}; + +/** + * Send a response frame to the Nova backend via the active WebSocket connection. + * `to` is the Nova userId, `replyTo` is the inbound messageId being replied to. + */ +export function sendNovaMessage(opts: SendNovaMessageOpts): SendNovaMessageResult { + const ws = getActiveNovaConnection(); + if (!ws || ws.readyState !== ws.OPEN) { + throw new Error("Nova WebSocket connection is not open"); + } + + const creds = resolveNovaCredentials(opts.cfg.channels?.nova); + if (!creds) { + throw new Error("Nova credentials not configured"); + } + + const messageId = crypto.randomUUID(); + const frame = JSON.stringify({ + action: "response", + type: opts.done !== false ? "done" : "chunk", + text: opts.text, + messageId, + replyTo: opts.replyTo ?? "", + }); + + ws.send(frame); + + return { messageId, conversationId: opts.to }; +} diff --git a/extensions/nova/src/types.ts b/extensions/nova/src/types.ts new file mode 100644 index 0000000000000..6842b9609ef95 --- /dev/null +++ b/extensions/nova/src/types.ts @@ -0,0 +1,45 @@ +/** Nova channel configuration stored under `channels.nova`. */ +export type NovaConfig = { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + userId?: string; + dmPolicy?: string; + allowFrom?: Array; + reconnectBaseDelayMs?: number; + heartbeatIntervalMs?: number; +}; + +/** Fully resolved credentials (all required fields present). */ +export type NovaCredentials = { + baseUrl: string; + apiKey: string; + userId: string; +}; + +/** Inbound message pushed to OpenClaw via WebSocket. */ +export type NovaInboundMessage = { + action: "message"; + userId: string; + text: string; + messageId: string; + timestamp: number; +}; + +/** Outbound response frame sent by OpenClaw via WebSocket. */ +export type NovaOutboundFrame = { + action: "response"; + type: "chunk" | "done"; + text: string; + messageId: string; + replyTo: string; +}; + +/** Heartbeat frame sent periodically to keep the WebSocket connection alive. */ +export type NovaHeartbeatFrame = { + action: "ping"; + timestamp: number; +}; + +/** Any frame OpenClaw sends over the WebSocket. */ +export type NovaOutgoingFrame = NovaOutboundFrame | NovaHeartbeatFrame; From a5ed1672f5f8d8194054f75155627aeb78d366f3 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 14:10:53 -0500 Subject: [PATCH 03/18] fix(nova): critical bugs in channel plugin WebSocket handling - Add required deviceId param to WebSocket URL (server returns 400 without it) - Await sendNovaMessage() in deliver callback to propagate errors - Block all messages when allowlist is empty instead of accepting all - Store reconnect timer and clear on abort to prevent resource leaks - Fix off-by-one in reconnect attempt logging vs delay calculation - Use WebSocket.OPEN class constant consistently in send.ts Co-Authored-By: Claude Opus 4.6 --- extensions/nova/src/credentials.test.ts | 84 +++++++++++++++++++------ extensions/nova/src/credentials.ts | 13 +++- extensions/nova/src/monitor.ts | 22 +++++-- extensions/nova/src/send.ts | 3 +- extensions/nova/src/types.ts | 2 + 5 files changed, 96 insertions(+), 28 deletions(-) diff --git a/extensions/nova/src/credentials.test.ts b/extensions/nova/src/credentials.test.ts index 0c65d08395570..c2252a30959e3 100644 --- a/extensions/nova/src/credentials.test.ts +++ b/extensions/nova/src/credentials.test.ts @@ -9,15 +9,18 @@ describe("resolveNovaCredentials", () => { savedEnv.NOVA_BASE_URL = process.env.NOVA_BASE_URL; savedEnv.NOVA_API_KEY = process.env.NOVA_API_KEY; savedEnv.NOVA_USER_ID = process.env.NOVA_USER_ID; + savedEnv.NOVA_DEVICE_ID = process.env.NOVA_DEVICE_ID; delete process.env.NOVA_BASE_URL; delete process.env.NOVA_API_KEY; delete process.env.NOVA_USER_ID; + delete process.env.NOVA_DEVICE_ID; }); afterEach(() => { process.env.NOVA_BASE_URL = savedEnv.NOVA_BASE_URL; process.env.NOVA_API_KEY = savedEnv.NOVA_API_KEY; process.env.NOVA_USER_ID = savedEnv.NOVA_USER_ID; + process.env.NOVA_DEVICE_ID = savedEnv.NOVA_DEVICE_ID; }); it("resolves credentials from config", () => { @@ -26,22 +29,28 @@ describe("resolveNovaCredentials", () => { apiKey: "key-123", userId: "user-001", }; - expect(resolveNovaCredentials(cfg)).toEqual({ - baseUrl: "wss://custom.example.com", - apiKey: "key-123", - userId: "user-001", - }); + const result = resolveNovaCredentials(cfg); + expect(result).toEqual( + expect.objectContaining({ + baseUrl: "wss://custom.example.com", + apiKey: "key-123", + userId: "user-001", + }), + ); + expect(result?.deviceId).toBeDefined(); }); it("falls back to env vars when config is empty", () => { process.env.NOVA_BASE_URL = "wss://env.example.com"; process.env.NOVA_API_KEY = "env-key"; process.env.NOVA_USER_ID = "env-user"; - expect(resolveNovaCredentials({})).toEqual({ - baseUrl: "wss://env.example.com", - apiKey: "env-key", - userId: "env-user", - }); + expect(resolveNovaCredentials({})).toEqual( + expect.objectContaining({ + baseUrl: "wss://env.example.com", + apiKey: "env-key", + userId: "env-user", + }), + ); }); it("prefers config over env vars", () => { @@ -53,11 +62,13 @@ describe("resolveNovaCredentials", () => { apiKey: "cfg-key", userId: "cfg-user", }; - expect(resolveNovaCredentials(cfg)).toEqual({ - baseUrl: "wss://config.example.com", - apiKey: "cfg-key", - userId: "cfg-user", - }); + expect(resolveNovaCredentials(cfg)).toEqual( + expect.objectContaining({ + baseUrl: "wss://config.example.com", + apiKey: "cfg-key", + userId: "cfg-user", + }), + ); }); it("uses default baseUrl when not specified", () => { @@ -96,11 +107,13 @@ describe("resolveNovaCredentials", () => { apiKey: " key ", userId: " user ", }; - expect(resolveNovaCredentials(cfg)).toEqual({ - baseUrl: "wss://example.com", - apiKey: "key", - userId: "user", - }); + expect(resolveNovaCredentials(cfg)).toEqual( + expect.objectContaining({ + baseUrl: "wss://example.com", + apiKey: "key", + userId: "user", + }), + ); }); it("uses default baseUrl for whitespace-only baseUrl", () => { @@ -112,4 +125,35 @@ describe("resolveNovaCredentials", () => { const result = resolveNovaCredentials(cfg); expect(result?.baseUrl).toBe("wss://ws.nova-claw.agi.amazon.dev"); }); + + it("uses deviceId from config when provided", () => { + const cfg: NovaConfig = { + apiKey: "key", + userId: "user", + deviceId: "my-device-42", + }; + const result = resolveNovaCredentials(cfg); + expect(result?.deviceId).toBe("my-device-42"); + }); + + it("uses deviceId from env var when config is empty", () => { + process.env.NOVA_DEVICE_ID = "env-device-99"; + const cfg: NovaConfig = { + apiKey: "key", + userId: "user", + }; + const result = resolveNovaCredentials(cfg); + expect(result?.deviceId).toBe("env-device-99"); + }); + + it("generates a stable deviceId across calls when not configured", () => { + const cfg: NovaConfig = { + apiKey: "key", + userId: "user", + }; + const result1 = resolveNovaCredentials(cfg); + const result2 = resolveNovaCredentials(cfg); + expect(result1?.deviceId).toBeDefined(); + expect(result1?.deviceId).toBe(result2?.deviceId); + }); }); diff --git a/extensions/nova/src/credentials.ts b/extensions/nova/src/credentials.ts index b17db129fd3df..ae7db03bf76b1 100644 --- a/extensions/nova/src/credentials.ts +++ b/extensions/nova/src/credentials.ts @@ -2,6 +2,12 @@ import type { NovaConfig, NovaCredentials } from "./types.js"; const DEFAULT_BASE_URL = "wss://ws.nova-claw.agi.amazon.dev"; +/** + * Stable deviceId generated once per process lifetime. + * Reused across reconnects so the server can correlate sessions to the same device. + */ +let cachedDeviceId: string | undefined; + /** * Resolve Nova credentials from config with env var fallbacks. * Returns `undefined` when any required field is missing. @@ -15,5 +21,10 @@ export function resolveNovaCredentials(cfg?: NovaConfig): NovaCredentials | unde return undefined; } - return { baseUrl, apiKey, userId }; + const deviceId = + cfg?.deviceId?.trim() || + process.env.NOVA_DEVICE_ID?.trim() || + (cachedDeviceId ??= crypto.randomUUID()); + + return { baseUrl, apiKey, userId, deviceId }; } diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 1d2d06f6e8330..9b0c8000bc559 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -66,6 +66,11 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise const onAbort = () => { logger.info("nova: abort signal received, closing connection"); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + clearHeartbeat(); const ws = activeWs; activeWs = null; setActiveNovaConnection(null); @@ -79,6 +84,7 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise let activeWs: WebSocket | null = null; let heartbeatTimer: ReturnType | null = null; + let reconnectTimer: ReturnType | null = null; function clearHeartbeat() { if (heartbeatTimer) { @@ -101,11 +107,11 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise resolveMonitor(); return; } + attempt++; const jitter = Math.random() * 0.3 + 0.85; // 0.85..1.15 const delay = Math.min(reconnectBaseMs * 2 ** attempt * jitter, MAX_RECONNECT_MS); - attempt++; logger.info(`nova: reconnecting in ${Math.round(delay)}ms (attempt ${attempt})`); - setTimeout(connect, delay); + reconnectTimer = setTimeout(connect, delay); } function connect() { @@ -114,7 +120,7 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise return; } - const url = `${creds.baseUrl}?userId=${encodeURIComponent(creds.userId)}`; + const url = `${creds.baseUrl}?userId=${encodeURIComponent(creds.userId)}&deviceId=${encodeURIComponent(creds.deviceId)}`; logger.info(`nova: connecting to ${creds.baseUrl}`); const ws = new WebSocket(url, { @@ -166,8 +172,12 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise String(entry).trim().toLowerCase(), ); - // Enforce allowlist policy - if (dmPolicy === "allowlist" && allowFrom.length > 0 && !allowFrom.includes("*")) { + // Enforce allowlist policy — an empty allowlist blocks everyone + if (dmPolicy === "allowlist" && !allowFrom.includes("*")) { + if (allowFrom.length === 0) { + logger.info(`nova: message from ${msg.userId} dropped (allowlist is empty)`); + return; + } const senderId = msg.userId.trim().toLowerCase(); if (!allowFrom.includes(senderId)) { logger.info(`nova: message from ${msg.userId} dropped (not in allowlist)`); @@ -238,7 +248,7 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise if (!fullText.trim()) { return; } - sendNovaMessage({ + await sendNovaMessage({ cfg: msgCfg, to: msg.userId, text: fullText, diff --git a/extensions/nova/src/send.ts b/extensions/nova/src/send.ts index ec7597540e994..029217edafc8c 100644 --- a/extensions/nova/src/send.ts +++ b/extensions/nova/src/send.ts @@ -1,3 +1,4 @@ +import WebSocket from "ws"; import type { NovaConfig } from "./types.js"; import { getActiveNovaConnection } from "./connection.js"; import { resolveNovaCredentials } from "./credentials.js"; @@ -21,7 +22,7 @@ export type SendNovaMessageResult = { */ export function sendNovaMessage(opts: SendNovaMessageOpts): SendNovaMessageResult { const ws = getActiveNovaConnection(); - if (!ws || ws.readyState !== ws.OPEN) { + if (!ws || ws.readyState !== WebSocket.OPEN) { throw new Error("Nova WebSocket connection is not open"); } diff --git a/extensions/nova/src/types.ts b/extensions/nova/src/types.ts index 6842b9609ef95..f552577272e00 100644 --- a/extensions/nova/src/types.ts +++ b/extensions/nova/src/types.ts @@ -4,6 +4,7 @@ export type NovaConfig = { baseUrl?: string; apiKey?: string; userId?: string; + deviceId?: string; dmPolicy?: string; allowFrom?: Array; reconnectBaseDelayMs?: number; @@ -15,6 +16,7 @@ export type NovaCredentials = { baseUrl: string; apiKey: string; userId: string; + deviceId: string; }; /** Inbound message pushed to OpenClaw via WebSocket. */ From 052ac5ded5f5271a251c4325350e23153a214a2a Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 14:35:39 -0500 Subject: [PATCH 04/18] fix(nova): use correct plugin SDK API for resolveStorePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.config.resolveStorePath does not exist — use core.channel.session.resolveStorePath(cfg.session?.store, { agentId }) matching the pattern used by Matrix and MSTeams extensions. Co-Authored-By: Claude Opus 4.6 --- extensions/nova/src/monitor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 9b0c8000bc559..4d3606ea1251e 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -195,7 +195,9 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise peer: { kind: "user", id: msg.userId }, }); - const storePath = core.config.resolveStorePath(route.agentId); + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: msg.text, From 09a56744c03e012b8d0aecef981a39b5d8c1b086 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 14:50:05 -0500 Subject: [PATCH 05/18] test(nova): add E2E WebSocket test script Python script that sends a message to the bot via the API Gateway Management API and polls the EC2 session transcripts for the response. Supports both direct WS and inject modes. Co-Authored-By: Claude Opus 4.6 --- scripts/test_nova_ws.py | 238 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 scripts/test_nova_ws.py diff --git a/scripts/test_nova_ws.py b/scripts/test_nova_ws.py new file mode 100644 index 0000000000000..241210b7f4a09 --- /dev/null +++ b/scripts/test_nova_ws.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +E2E test for the Nova WebSocket channel. + +Sends a message to the bot via the API Gateway Management API and reads +the bot's response from the session transcript on the EC2 instance. + +Usage: + pip install websockets boto3 + python scripts/test_nova_ws.py [--message "your test message"] + +Requires: + - AWS credentials with access to account 585578473840 + - SSH key at ~/.ssh/HyperionBotServiceEc2.pem + - The bot's gateway must be running and connected on the EC2 +""" + +import argparse +import asyncio +import json +import subprocess +import time +import uuid + +import boto3 +import websockets + +# --- Configuration --- +WS_ENDPOINT = "wss://ws.nova-claw.agi.amazon.dev" +APIGW_MANAGEMENT_URL = "https://3x0jx6dr52.execute-api.us-east-1.amazonaws.com/prod" +DDB_TABLE = "nova-personal-connections-prod" +REGION = "us-east-1" + +# Auth +API_KEY = "608a34f3-69fd-4dd4-aa88-cb467f2ccb68" + +# EC2 connection +EC2_HOST = "ec2-user@ec2-3-235-188-241.compute-1.amazonaws.com" +SSH_KEY = "~/.ssh/HyperionBotServiceEc2.pem" +BOT_SOURCE_IP = "3.235.188.241" + +# Test user identity +TEST_USER_ID = "test-user" +TEST_DEVICE_ID = str(uuid.uuid4()) + + +def ssh_cmd(cmd: str, timeout: int = 15) -> str: + """Run a command on the EC2 via SSH.""" + result = subprocess.run( + ["ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no", EC2_HOST, cmd], + capture_output=True, text=True, timeout=timeout, + ) + return result.stdout.strip() + + +def get_bot_connection_id() -> str: + """Look up the bot's connectionId from DynamoDB by its EC2 IP.""" + ddb = boto3.client("dynamodb", region_name=REGION) + resp = ddb.scan(TableName=DDB_TABLE) + for item in resp.get("Items", []): + device_id = item.get("deviceId", {}).get("S", "") + source_ip = ( + item.get("metadata", {}).get("M", {}).get("sourceIp", {}).get("S", "") + ) + conn_id = item.get("connectionId", {}).get("S", "") + if source_ip == BOT_SOURCE_IP and conn_id: + print(f" connectionId: {conn_id}") + print(f" deviceId: {device_id}") + print(f" sourceIp: {source_ip}") + print(f" connectedAt: {item.get('connectedAt', {}).get('S', '')}") + return conn_id + raise RuntimeError(f"No bot connection found (sourceIp={BOT_SOURCE_IP})") + + +def send_to_bot(connection_id: str, message: dict): + """Post a message to the bot's connection via the Management API.""" + client = boto3.client( + "apigatewaymanagementapi", + endpoint_url=APIGW_MANAGEMENT_URL, + region_name=REGION, + ) + client.post_to_connection( + ConnectionId=connection_id, + Data=json.dumps(message).encode("utf-8"), + ) + + +def count_assistant_messages(session_dir: str = "/home/ec2-user/.openclaw/agents/main/sessions") -> dict[str, int]: + """Count assistant messages per session file on EC2.""" + raw = ssh_cmd( + f'for f in {session_dir}/*.jsonl; do ' + f' n=$(grep -c \'"role":"assistant"\' "$f" 2>/dev/null || echo 0); ' + f' echo "$(basename "$f" .jsonl) $n"; ' + f'done' + ) + counts: dict[str, int] = {} + for line in raw.splitlines(): + parts = line.strip().split() + if len(parts) == 2: + counts[parts[0]] = int(parts[1]) + return counts + + +def read_last_assistant_response( + session_dir: str = "/home/ec2-user/.openclaw/agents/main/sessions", +) -> tuple[str | None, str | None]: + """Read the most recent assistant response from any session on EC2.""" + raw = ssh_cmd( + f"ls -t {session_dir}/*.jsonl 2>/dev/null | head -1" + ) + if not raw or not raw.endswith(".jsonl"): + return None, None + session_id = raw.rsplit("/", 1)[-1].replace(".jsonl", "") + path = f"{session_dir}/{session_id}.jsonl" + content = ssh_cmd(f"cat {path} 2>/dev/null") + if not content: + return session_id, None + + last_response = None + for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") == "message": + msg = entry.get("message", {}) + if msg.get("role") == "assistant": + parts = msg.get("content", []) + text = "".join( + p.get("text", "") for p in parts if p.get("type") == "text" + ) + if text: + last_response = text + return session_id, last_response + + +def wait_for_bot_response( + pre_counts: dict[str, int], timeout_secs: int = 60, poll_interval: float = 2.0 +) -> tuple[str | None, str | None]: + """Poll until a new assistant message appears in any session.""" + start = time.time() + while time.time() - start < timeout_secs: + elapsed = int(time.time() - start) + if elapsed > 0 and elapsed % 5 == 0: + print(f" ... polling ({elapsed}s)") + current = count_assistant_messages() + for sid, count in current.items(): + prev = pre_counts.get(sid, 0) + if count > prev: + # New assistant message — read it + time.sleep(1) # let the file finish writing + _, response = read_last_assistant_response() + if response: + return sid, response + time.sleep(poll_interval) + return None, None + + +async def run_test(test_message: str, timeout_secs: int): + """Run the E2E test.""" + print("=" * 60) + print("Nova WebSocket E2E Test") + print("=" * 60) + + # Step 1: Snapshot existing assistant message counts + print("\n[1] Snapshotting session state on EC2...") + pre_counts = count_assistant_messages() + total = sum(pre_counts.values()) + print(f" {len(pre_counts)} session(s), {total} assistant message(s)") + + # Step 2: Find the bot's connection + print("\n[2] Looking up bot connection from DynamoDB...") + bot_conn_id = get_bot_connection_id() + + # Step 3: Send message to bot + message_id = str(uuid.uuid4()) + inbound = { + "action": "message", + "userId": TEST_USER_ID, + "text": test_message, + "messageId": message_id, + "timestamp": int(time.time() * 1000), + } + + print(f"\n[3] Sending message to bot...") + print(f" messageId: {message_id}") + print(f" text: {test_message}") + + try: + send_to_bot(bot_conn_id, inbound) + print(" Sent successfully!") + except Exception as e: + print(f" ERROR: {e}") + return + + # Step 4: Wait for response + print(f"\n[4] Waiting for bot response (polling EC2 sessions, timeout {timeout_secs}s)...") + print("-" * 60) + + session_id, response = wait_for_bot_response(pre_counts, timeout_secs) + + print("-" * 60) + if response: + print(f"\n[Result] Session: {session_id}") + print(f"\nBot response:") + print(response) + else: + print("\n[Result] No response within timeout.") + print(" Check bot logs:") + print(f" ssh -i {SSH_KEY} {EC2_HOST} \\") + print(" 'grep nova /tmp/openclaw/openclaw-*.log | tail -20'") + + print("\n" + "=" * 60) + print("Test complete.") + + +def main(): + parser = argparse.ArgumentParser(description="Test Nova WebSocket E2E") + parser.add_argument( + "--message", "-m", + default="Hello! What is 2 + 2?", + help="Message to send to the bot", + ) + parser.add_argument( + "--timeout", "-t", + type=int, + default=60, + help="Seconds to wait for response (default: 60)", + ) + args = parser.parse_args() + asyncio.run(run_test(args.message, args.timeout)) + + +if __name__ == "__main__": + main() From 36b9f5134ee9839e172410516dca9bc6a4ffa58c Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 16:09:12 -0500 Subject: [PATCH 06/18] fix(nova): remove hardcoded API key from test script Read NOVA_API_KEY from environment variable instead of embedding the secret in source control. Co-Authored-By: Claude Opus 4.6 --- scripts/test_nova_ws.py | 365 +++++++++++++++++++++++----------------- 1 file changed, 211 insertions(+), 154 deletions(-) diff --git a/scripts/test_nova_ws.py b/scripts/test_nova_ws.py index 241210b7f4a09..6f78380b74e1b 100644 --- a/scripts/test_nova_ws.py +++ b/scripts/test_nova_ws.py @@ -2,23 +2,25 @@ """ E2E test for the Nova WebSocket channel. -Sends a message to the bot via the API Gateway Management API and reads -the bot's response from the session transcript on the EC2 instance. +Connects as a user via WebSocket, sends a message to a bot via the +API Gateway Management API, and waits for the bot's response on the +WebSocket. Works with any bot — no SSH or EC2 access required. Usage: pip install websockets boto3 python scripts/test_nova_ws.py [--message "your test message"] + python scripts/test_nova_ws.py --device-id + python scripts/test_nova_ws.py --connection-id + python scripts/test_nova_ws.py --list Requires: - - AWS credentials with access to account 585578473840 - - SSH key at ~/.ssh/HyperionBotServiceEc2.pem - - The bot's gateway must be running and connected on the EC2 + - AWS credentials with access to the DynamoDB table and APIGW Management API """ import argparse import asyncio import json -import subprocess +import os import time import uuid @@ -31,45 +33,79 @@ DDB_TABLE = "nova-personal-connections-prod" REGION = "us-east-1" -# Auth -API_KEY = "608a34f3-69fd-4dd4-aa88-cb467f2ccb68" - -# EC2 connection -EC2_HOST = "ec2-user@ec2-3-235-188-241.compute-1.amazonaws.com" -SSH_KEY = "~/.ssh/HyperionBotServiceEc2.pem" -BOT_SOURCE_IP = "3.235.188.241" +# Auth – set NOVA_API_KEY in your environment or .env file +API_KEY = os.environ.get("NOVA_API_KEY", "") # Test user identity TEST_USER_ID = "test-user" TEST_DEVICE_ID = str(uuid.uuid4()) -def ssh_cmd(cmd: str, timeout: int = 15) -> str: - """Run a command on the EC2 via SSH.""" - result = subprocess.run( - ["ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no", EC2_HOST, cmd], - capture_output=True, text=True, timeout=timeout, - ) - return result.stdout.strip() - - -def get_bot_connection_id() -> str: - """Look up the bot's connectionId from DynamoDB by its EC2 IP.""" +def list_connections() -> list[dict]: + """List all active connections from DynamoDB.""" ddb = boto3.client("dynamodb", region_name=REGION) resp = ddb.scan(TableName=DDB_TABLE) + connections = [] for item in resp.get("Items", []): - device_id = item.get("deviceId", {}).get("S", "") - source_ip = ( - item.get("metadata", {}).get("M", {}).get("sourceIp", {}).get("S", "") - ) - conn_id = item.get("connectionId", {}).get("S", "") - if source_ip == BOT_SOURCE_IP and conn_id: - print(f" connectionId: {conn_id}") - print(f" deviceId: {device_id}") - print(f" sourceIp: {source_ip}") - print(f" connectedAt: {item.get('connectedAt', {}).get('S', '')}") - return conn_id - raise RuntimeError(f"No bot connection found (sourceIp={BOT_SOURCE_IP})") + conn = { + "connectionId": item.get("connectionId", {}).get("S", ""), + "deviceId": item.get("deviceId", {}).get("S", ""), + "sourceIp": ( + item.get("metadata", {}).get("M", {}).get("sourceIp", {}).get("S", "") + ), + "connectedAt": item.get("connectedAt", {}).get("S", ""), + } + if conn["connectionId"]: + connections.append(conn) + # Sort by connectedAt descending (most recent first) + connections.sort(key=lambda c: c["connectedAt"], reverse=True) + return connections + + +def find_connection( + connection_id: str | None = None, + device_id: str | None = None, + source_ip: str | None = None, +) -> dict: + """Find a bot connection by connectionId, deviceId, or sourceIp.""" + connections = list_connections() + if not connections: + raise RuntimeError("No connections found in DynamoDB") + + if connection_id: + for c in connections: + if c["connectionId"] == connection_id: + return c + raise RuntimeError(f"No connection found with connectionId={connection_id}") + + if device_id: + for c in connections: + if c["deviceId"] == device_id: + return c + raise RuntimeError(f"No connection found with deviceId={device_id}") + + if source_ip: + for c in connections: + if c["sourceIp"] == source_ip: + return c + raise RuntimeError(f"No connection found with sourceIp={source_ip}") + + # Default: return the most recent connection + return connections[0] + + +def print_connections(connections: list[dict]): + """Pretty-print a list of connections.""" + if not connections: + print(" (no connections)") + return + for i, c in enumerate(connections): + print(f" [{i}] connectionId: {c['connectionId']}") + print(f" deviceId: {c['deviceId']}") + print(f" sourceIp: {c['sourceIp']}") + print(f" connectedAt: {c['connectedAt']}") + if i < len(connections) - 1: + print() def send_to_bot(connection_id: str, message: dict): @@ -85,138 +121,132 @@ def send_to_bot(connection_id: str, message: dict): ) -def count_assistant_messages(session_dir: str = "/home/ec2-user/.openclaw/agents/main/sessions") -> dict[str, int]: - """Count assistant messages per session file on EC2.""" - raw = ssh_cmd( - f'for f in {session_dir}/*.jsonl; do ' - f' n=$(grep -c \'"role":"assistant"\' "$f" 2>/dev/null || echo 0); ' - f' echo "$(basename "$f" .jsonl) $n"; ' - f'done' - ) - counts: dict[str, int] = {} - for line in raw.splitlines(): - parts = line.strip().split() - if len(parts) == 2: - counts[parts[0]] = int(parts[1]) - return counts - - -def read_last_assistant_response( - session_dir: str = "/home/ec2-user/.openclaw/agents/main/sessions", -) -> tuple[str | None, str | None]: - """Read the most recent assistant response from any session on EC2.""" - raw = ssh_cmd( - f"ls -t {session_dir}/*.jsonl 2>/dev/null | head -1" - ) - if not raw or not raw.endswith(".jsonl"): - return None, None - session_id = raw.rsplit("/", 1)[-1].replace(".jsonl", "") - path = f"{session_dir}/{session_id}.jsonl" - content = ssh_cmd(f"cat {path} 2>/dev/null") - if not content: - return session_id, None - - last_response = None - for line in content.splitlines(): - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - if entry.get("type") == "message": - msg = entry.get("message", {}) - if msg.get("role") == "assistant": - parts = msg.get("content", []) - text = "".join( - p.get("text", "") for p in parts if p.get("type") == "text" - ) - if text: - last_response = text - return session_id, last_response - - -def wait_for_bot_response( - pre_counts: dict[str, int], timeout_secs: int = 60, poll_interval: float = 2.0 -) -> tuple[str | None, str | None]: - """Poll until a new assistant message appears in any session.""" - start = time.time() - while time.time() - start < timeout_secs: - elapsed = int(time.time() - start) - if elapsed > 0 and elapsed % 5 == 0: - print(f" ... polling ({elapsed}s)") - current = count_assistant_messages() - for sid, count in current.items(): - prev = pre_counts.get(sid, 0) - if count > prev: - # New assistant message — read it - time.sleep(1) # let the file finish writing - _, response = read_last_assistant_response() - if response: - return sid, response - time.sleep(poll_interval) - return None, None - - -async def run_test(test_message: str, timeout_secs: int): +async def run_test( + test_message: str, + timeout_secs: int, + connection_id: str | None, + device_id: str | None, + source_ip: str | None, +): """Run the E2E test.""" print("=" * 60) print("Nova WebSocket E2E Test") print("=" * 60) - # Step 1: Snapshot existing assistant message counts - print("\n[1] Snapshotting session state on EC2...") - pre_counts = count_assistant_messages() - total = sum(pre_counts.values()) - print(f" {len(pre_counts)} session(s), {total} assistant message(s)") - - # Step 2: Find the bot's connection - print("\n[2] Looking up bot connection from DynamoDB...") - bot_conn_id = get_bot_connection_id() - - # Step 3: Send message to bot - message_id = str(uuid.uuid4()) - inbound = { - "action": "message", - "userId": TEST_USER_ID, - "text": test_message, - "messageId": message_id, - "timestamp": int(time.time() * 1000), - } - - print(f"\n[3] Sending message to bot...") - print(f" messageId: {message_id}") - print(f" text: {test_message}") + # Step 1: Find the bot's connection + print("\n[1] Looking up bot connection from DynamoDB...") + conn = find_connection(connection_id, device_id, source_ip) + bot_conn_id = conn["connectionId"] + print(f" connectionId: {conn['connectionId']}") + print(f" deviceId: {conn['deviceId']}") + print(f" sourceIp: {conn['sourceIp']}") + print(f" connectedAt: {conn['connectedAt']}") + + # Step 2: Connect as a user via WebSocket + print("\n[2] Connecting as test user via WebSocket...") + ws_url = f"{WS_ENDPOINT}?userId={TEST_USER_ID}&deviceId={TEST_DEVICE_ID}" + ws = await websockets.connect( + ws_url, + additional_headers={"Authorization": f"Bearer {API_KEY}"}, + ) + print(f" Connected (userId={TEST_USER_ID}, deviceId={TEST_DEVICE_ID})") try: - send_to_bot(bot_conn_id, inbound) - print(" Sent successfully!") - except Exception as e: - print(f" ERROR: {e}") - return + # Step 3: Send message to bot + message_id = str(uuid.uuid4()) + inbound = { + "action": "message", + "userId": TEST_USER_ID, + "text": test_message, + "messageId": message_id, + "timestamp": int(time.time() * 1000), + } + + print(f"\n[3] Sending message to bot...") + print(f" messageId: {message_id}") + print(f" text: {test_message}") - # Step 4: Wait for response - print(f"\n[4] Waiting for bot response (polling EC2 sessions, timeout {timeout_secs}s)...") - print("-" * 60) + try: + send_to_bot(bot_conn_id, inbound) + print(" Sent successfully!") + except Exception as e: + print(f" ERROR: {e}") + return - session_id, response = wait_for_bot_response(pre_counts, timeout_secs) + # Step 4: Wait for response on WebSocket + print(f"\n[4] Waiting for bot response on WebSocket (timeout {timeout_secs}s)...") + print("-" * 60) - print("-" * 60) - if response: - print(f"\n[Result] Session: {session_id}") - print(f"\nBot response:") - print(response) - else: - print("\n[Result] No response within timeout.") - print(" Check bot logs:") - print(f" ssh -i {SSH_KEY} {EC2_HOST} \\") - print(" 'grep nova /tmp/openclaw/openclaw-*.log | tail -20'") + chunks: list[str] = [] + start = time.time() + + try: + while time.time() - start < timeout_secs: + remaining = timeout_secs - (time.time() - start) + if remaining <= 0: + break + try: + raw = await asyncio.wait_for(ws.recv(), timeout=min(remaining, 5.0)) + except asyncio.TimeoutError: + elapsed = int(time.time() - start) + if elapsed > 0 and elapsed % 10 == 0: + print(f" ... waiting ({elapsed}s)") + continue + + try: + frame = json.loads(raw) + except json.JSONDecodeError: + continue + + action = frame.get("action", "") + frame_type = frame.get("type", "") + reply_to = frame.get("replyTo", "") + text = frame.get("text", "") + + # Only process response frames for our message + if action == "response" and reply_to == message_id: + if text: + chunks.append(text) + if frame_type == "done": + break + elif action == "ping": + # Ignore heartbeats + continue + else: + # Log unexpected frames for debugging + print(f" [debug] frame: action={action} type={frame_type} replyTo={reply_to}") + + except websockets.ConnectionClosed: + print(" WebSocket connection closed unexpectedly") + + print("-" * 60) + if chunks: + response = "".join(chunks) + print(f"\nBot response:") + print(response) + else: + print("\n[Result] No response within timeout.") + print(" The bot may not be running or may not have processed the message.") + + finally: + await ws.close() print("\n" + "=" * 60) print("Test complete.") +async def run_list(): + """List all active connections.""" + print("=" * 60) + print("Active Nova WebSocket Connections") + print("=" * 60) + print() + connections = list_connections() + print_connections(connections) + print() + print(f"Total: {len(connections)} connection(s)") + + def main(): parser = argparse.ArgumentParser(description="Test Nova WebSocket E2E") parser.add_argument( @@ -230,8 +260,35 @@ def main(): default=60, help="Seconds to wait for response (default: 60)", ) + parser.add_argument( + "--connection-id", + help="Target bot's connectionId (from DynamoDB)", + ) + parser.add_argument( + "--device-id", + help="Target bot's deviceId (from DynamoDB)", + ) + parser.add_argument( + "--source-ip", + help="Target bot's source IP (from DynamoDB)", + ) + parser.add_argument( + "--list", "-l", + action="store_true", + help="List all active connections and exit", + ) args = parser.parse_args() - asyncio.run(run_test(args.message, args.timeout)) + + if args.list: + asyncio.run(run_list()) + else: + asyncio.run(run_test( + args.message, + args.timeout, + args.connection_id, + args.device_id, + args.source_ip, + )) if __name__ == "__main__": From 92bbe1946249167328cab522a87f575e3eae8883 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 12 Feb 2026 17:22:38 -0500 Subject: [PATCH 07/18] fix(nova): route bot responses to user WebSocket connections The deliver callback was reading payload.parts (always empty) instead of payload.text where the framework actually places the response. The response frame also lacked a 'to' field, so the server-side Lambda had no way to resolve the target user's connection. The test script could only look up connections by connectionId (which changes on reconnect). - monitor.ts: read payload.text in the deliver callback - send.ts: include target userId ('to') in the response frame - test_nova_ws.py: add --user-id flag for stable connection lookups Also deployed to AWS (not in this repo): - connect Lambda: stores userId from query string in DynamoDB - message Lambda: forwards bot responses to the target user's WebSocket via the API Gateway Management API - IAM: added execute-api:Invoke to the message Lambda role Co-Authored-By: Claude Opus 4.6 --- extensions/nova/src/monitor.ts | 12 ++++++++---- extensions/nova/src/send.ts | 1 + scripts/test_nova_ws.py | 20 ++++++++++++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 4d3606ea1251e..7fcdeec744526 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -243,10 +243,14 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(msgCfg, route.agentId), deliver: async (payload) => { - const chunks = payload.parts ?? []; - const fullText = chunks - .map((part) => (typeof part === "string" ? part : (part.text ?? ""))) - .join(""); + const fullText = + typeof payload.text === "string" + ? payload.text + : (payload.parts ?? []) + .map((part: string | { text?: string }) => + typeof part === "string" ? part : (part.text ?? ""), + ) + .join(""); if (!fullText.trim()) { return; } diff --git a/extensions/nova/src/send.ts b/extensions/nova/src/send.ts index 029217edafc8c..edeed228d17f9 100644 --- a/extensions/nova/src/send.ts +++ b/extensions/nova/src/send.ts @@ -38,6 +38,7 @@ export function sendNovaMessage(opts: SendNovaMessageOpts): SendNovaMessageResul text: opts.text, messageId, replyTo: opts.replyTo ?? "", + to: opts.to, }); ws.send(frame); diff --git a/scripts/test_nova_ws.py b/scripts/test_nova_ws.py index 6f78380b74e1b..356c30f9ce6a6 100644 --- a/scripts/test_nova_ws.py +++ b/scripts/test_nova_ws.py @@ -50,6 +50,7 @@ def list_connections() -> list[dict]: conn = { "connectionId": item.get("connectionId", {}).get("S", ""), "deviceId": item.get("deviceId", {}).get("S", ""), + "userId": item.get("userId", {}).get("S", ""), "sourceIp": ( item.get("metadata", {}).get("M", {}).get("sourceIp", {}).get("S", "") ), @@ -65,9 +66,10 @@ def list_connections() -> list[dict]: def find_connection( connection_id: str | None = None, device_id: str | None = None, + user_id: str | None = None, source_ip: str | None = None, ) -> dict: - """Find a bot connection by connectionId, deviceId, or sourceIp.""" + """Find a bot connection by connectionId, deviceId, userId, or sourceIp.""" connections = list_connections() if not connections: raise RuntimeError("No connections found in DynamoDB") @@ -78,6 +80,12 @@ def find_connection( return c raise RuntimeError(f"No connection found with connectionId={connection_id}") + if user_id: + for c in connections: + if c["userId"] == user_id: + return c + raise RuntimeError(f"No connection found with userId={user_id}") + if device_id: for c in connections: if c["deviceId"] == device_id: @@ -101,6 +109,7 @@ def print_connections(connections: list[dict]): return for i, c in enumerate(connections): print(f" [{i}] connectionId: {c['connectionId']}") + print(f" userId: {c['userId'] or '(not set)'}") print(f" deviceId: {c['deviceId']}") print(f" sourceIp: {c['sourceIp']}") print(f" connectedAt: {c['connectedAt']}") @@ -126,6 +135,7 @@ async def run_test( timeout_secs: int, connection_id: str | None, device_id: str | None, + user_id: str | None, source_ip: str | None, ): """Run the E2E test.""" @@ -135,9 +145,10 @@ async def run_test( # Step 1: Find the bot's connection print("\n[1] Looking up bot connection from DynamoDB...") - conn = find_connection(connection_id, device_id, source_ip) + conn = find_connection(connection_id, device_id, user_id, source_ip) bot_conn_id = conn["connectionId"] print(f" connectionId: {conn['connectionId']}") + print(f" userId: {conn['userId'] or '(not set)'}") print(f" deviceId: {conn['deviceId']}") print(f" sourceIp: {conn['sourceIp']}") print(f" connectedAt: {conn['connectedAt']}") @@ -268,6 +279,10 @@ def main(): "--device-id", help="Target bot's deviceId (from DynamoDB)", ) + parser.add_argument( + "--user-id", + help="Target bot's userId (from DynamoDB) — stable across reconnects", + ) parser.add_argument( "--source-ip", help="Target bot's source IP (from DynamoDB)", @@ -287,6 +302,7 @@ def main(): args.timeout, args.connection_id, args.device_id, + args.user_id, args.source_ip, )) From d56bd5c8049fe449bfbcb0e4d28efed6c8cd41d5 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Wed, 11 Mar 2026 12:54:18 -0400 Subject: [PATCH 08/18] feat(hyperion): add multi-tenant runtime layer, AgentCore extension, and unit tests Multi-tenant DynamoDB integration layer (src/hyperion/): - TenantConfigLoader: loads tenant_config + channel links + credentials, assembles OpenClawConfig - HyperionDynamoDBClient: DDB operations for all 4 tables with multi-agent composite keys - HyperionPairingStore: pairing code CRUD with 5-min TTL and retry logic - SessionManager: session key parsing, tenant/agent ID extraction, memory namespacing - UserCredentialStore: KMS envelope encryption for per-user API keys AgentCore ACP extension (extensions/agentcore/): - AgentCoreRuntime: implements OC AcpRuntime interface (ensureSession, runTurn, cancel, close) - Pre-turn: loads tenant context + retrieves memory in parallel - Post-turn: fires memory extraction job (fire-and-forget) - Error classification: throttling, resource not found, service unavailable - Config loader: SSM parameter-based configuration with local override support Hyperion gateway plugin (extensions/hyperion/): - Plugin entry that registers createHyperionPluginService() - Creates AWS SDK clients, calls createHyperionRuntime(), stores as global singleton - Stage resolution from plugin config / HYPERION_STAGE / STACK_NAME Nova channel integration: - monitor.ts loads per-tenant config via getHyperionRuntime() on each inbound message Unit tests (158 tests across 6 files): - Vitest 4.x compatible mocks (regular functions for constructors) - Properly typed test helpers, zero as-any casts Co-Authored-By: Claude Opus 4.6 --- config/openclaw.json5 | 43 + extensions/agentcore/index.ts | 45 + extensions/agentcore/openclaw.plugin.json | 48 ++ extensions/agentcore/package.json | 18 + extensions/agentcore/src/config.test.ts | 229 ++++++ extensions/agentcore/src/config.ts | 86 ++ extensions/agentcore/src/index.ts | 4 + extensions/agentcore/src/runtime.test.ts | 952 ++++++++++++++++++++++ extensions/agentcore/src/runtime.ts | 509 ++++++++++++ extensions/agentcore/src/service.ts | 90 ++ extensions/agentcore/src/types.ts | 37 + extensions/hyperion/index.ts | 15 + extensions/hyperion/openclaw.plugin.json | 24 + extensions/hyperion/package.json | 19 + extensions/hyperion/src/env.ts | 41 + extensions/hyperion/src/globals.ts | 38 + extensions/hyperion/src/index.ts | 8 + extensions/hyperion/src/service.ts | 81 ++ extensions/nova/src/monitor.ts | 49 +- src/hyperion/channel-identity-resolver.ts | 132 +++ src/hyperion/dynamodb-client.test.ts | 577 +++++++++++++ src/hyperion/dynamodb-client.ts | 232 ++++++ src/hyperion/index.ts | 68 ++ src/hyperion/pairing-store.test.ts | 243 ++++++ src/hyperion/pairing-store.ts | 164 ++++ src/hyperion/runtime.ts | 90 ++ src/hyperion/session-manager.test.ts | 177 ++++ src/hyperion/session-manager.ts | 158 ++++ src/hyperion/tenant-config-loader.test.ts | 249 ++++++ src/hyperion/tenant-config-loader.ts | 272 +++++++ src/hyperion/types.ts | 204 +++++ src/hyperion/user-credential-store.ts | 186 +++++ 32 files changed, 5072 insertions(+), 16 deletions(-) create mode 100644 config/openclaw.json5 create mode 100644 extensions/agentcore/index.ts create mode 100644 extensions/agentcore/openclaw.plugin.json create mode 100644 extensions/agentcore/package.json create mode 100644 extensions/agentcore/src/config.test.ts create mode 100644 extensions/agentcore/src/config.ts create mode 100644 extensions/agentcore/src/index.ts create mode 100644 extensions/agentcore/src/runtime.test.ts create mode 100644 extensions/agentcore/src/runtime.ts create mode 100644 extensions/agentcore/src/service.ts create mode 100644 extensions/agentcore/src/types.ts create mode 100644 extensions/hyperion/index.ts create mode 100644 extensions/hyperion/openclaw.plugin.json create mode 100644 extensions/hyperion/package.json create mode 100644 extensions/hyperion/src/env.ts create mode 100644 extensions/hyperion/src/globals.ts create mode 100644 extensions/hyperion/src/index.ts create mode 100644 extensions/hyperion/src/service.ts create mode 100644 src/hyperion/channel-identity-resolver.ts create mode 100644 src/hyperion/dynamodb-client.test.ts create mode 100644 src/hyperion/dynamodb-client.ts create mode 100644 src/hyperion/index.ts create mode 100644 src/hyperion/pairing-store.test.ts create mode 100644 src/hyperion/pairing-store.ts create mode 100644 src/hyperion/runtime.ts create mode 100644 src/hyperion/session-manager.test.ts create mode 100644 src/hyperion/session-manager.ts create mode 100644 src/hyperion/tenant-config-loader.test.ts create mode 100644 src/hyperion/tenant-config-loader.ts create mode 100644 src/hyperion/types.ts create mode 100644 src/hyperion/user-credential-store.ts diff --git a/config/openclaw.json5 b/config/openclaw.json5 new file mode 100644 index 0000000000000..324965ae2075b --- /dev/null +++ b/config/openclaw.json5 @@ -0,0 +1,43 @@ +// Hyperion Assistant Service — OC Gateway base config. +// Loaded via OC_GATEWAY_CONFIG secret in ECS container. +// Per-tenant config (model, tools, personality) is loaded from DynamoDB at runtime +// by the Hyperion plugin; this file only defines platform-level gateway settings. +{ + // Gateway binds to 0.0.0.0 on port 18789 (overridden by CMD in ECS task def). + gateway: { + mode: "local", + port: 18789, + bind: "lan", + auth: { + // ALB handles TLS termination; no gateway-level auth needed. + // WAF rate-limiting + ALB security group restrict access. + mode: "none", + }, + // ALB is a trusted reverse proxy — trust x-forwarded-for for client IP. + trustedProxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], + }, + + // ACP runtime — route agent turns to AgentCore (registered by agentcore extension). + acp: { + enabled: true, + backend: "agentcore", + }, + + // Extensions loaded at startup (built into the container image via OPENCLAW_EXTENSIONS). + plugins: { + enabled: true, + }, + + // Logging — structured JSON for CloudWatch ingestion. + logging: { + format: "json", + }, + + // Disable features not needed in headless server mode. + update: { + checkOnStart: false, + }, + discovery: { + mdns: { mode: "off" }, + }, +} diff --git a/extensions/agentcore/index.ts b/extensions/agentcore/index.ts new file mode 100644 index 0000000000000..5441a522b65ae --- /dev/null +++ b/extensions/agentcore/index.ts @@ -0,0 +1,45 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/acpx"; +import { createAgentCoreRuntimeService } from "./src/service.js"; + +type AgentCorePluginConfig = { + ssmPrefix?: string; + region?: string; + endpoint?: string; + invokeTimeoutMs?: number; +}; + +const plugin = { + id: "agentcore", + name: "AgentCore Runtime", + description: "ACP runtime backend powered by AWS Bedrock AgentCore.", + register(api: OpenClawPluginApi) { + const pluginConfig = (api.pluginConfig ?? {}) as AgentCorePluginConfig; + + // Derive SSM prefix from HYPERION_STAGE env var if not configured. + const stage = process.env.HYPERION_STAGE; + const ssmPrefix = + pluginConfig.ssmPrefix ?? (stage ? `/hyperion/${stage}/agentcore` : undefined); + + if (!ssmPrefix) { + api.logger.warn( + "AgentCore plugin: no ssmPrefix configured and HYPERION_STAGE not set. " + + "Set plugins.agentcore.ssmPrefix in config or HYPERION_STAGE env var.", + ); + return; + } + + const region = pluginConfig.region ?? process.env.AWS_REGION ?? "us-west-2"; + + api.registerService( + createAgentCoreRuntimeService({ + configSource: { + ssmPrefix, + region, + localOverride: pluginConfig.endpoint ? { endpoint: pluginConfig.endpoint } : undefined, + }, + }), + ); + }, +}; + +export default plugin; diff --git a/extensions/agentcore/openclaw.plugin.json b/extensions/agentcore/openclaw.plugin.json new file mode 100644 index 0000000000000..b0873f8af94f8 --- /dev/null +++ b/extensions/agentcore/openclaw.plugin.json @@ -0,0 +1,48 @@ +{ + "id": "agentcore", + "name": "AgentCore Runtime", + "description": "ACP runtime backend powered by AWS Bedrock AgentCore. Replaces embedded Pi Agent with per-tenant Firecracker microVMs.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "ssmPrefix": { + "type": "string", + "description": "SSM parameter path prefix (e.g. /hyperion/beta/agentcore)" + }, + "region": { + "type": "string", + "description": "AWS region for AgentCore API calls" + }, + "endpoint": { + "type": "string", + "description": "AgentCore endpoint override (for local testing)" + }, + "invokeTimeoutMs": { + "type": "number", + "minimum": 1000, + "description": "Timeout for agent invocations in milliseconds" + } + } + }, + "uiHints": { + "ssmPrefix": { + "label": "SSM Parameter Prefix", + "help": "SSM path prefix for AgentCore config (e.g. /hyperion/beta/agentcore). Runtime ARNs, memory config, and default model are read from sub-parameters." + }, + "region": { + "label": "AWS Region", + "help": "AWS region for AgentCore. Defaults to AWS_REGION env var or us-west-2." + }, + "endpoint": { + "label": "Endpoint Override", + "help": "Custom AgentCore endpoint for local development.", + "advanced": true + }, + "invokeTimeoutMs": { + "label": "Invoke Timeout (ms)", + "help": "Maximum time to wait for an agent invocation (default: 300000 = 5 minutes).", + "advanced": true + } + } +} diff --git a/extensions/agentcore/package.json b/extensions/agentcore/package.json new file mode 100644 index 0000000000000..0d83820639f56 --- /dev/null +++ b/extensions/agentcore/package.json @@ -0,0 +1,18 @@ +{ + "name": "@openclaw/agentcore", + "version": "2026.3.10", + "description": "OpenClaw ACP runtime backend via AWS Bedrock AgentCore", + "type": "module", + "dependencies": { + "@aws-sdk/client-bedrock-agentcore": "^3.0.0", + "@aws-sdk/client-ssm": "^3.0.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/agentcore/src/config.test.ts b/extensions/agentcore/src/config.test.ts new file mode 100644 index 0000000000000..cb0a467b405cc --- /dev/null +++ b/extensions/agentcore/src/config.test.ts @@ -0,0 +1,229 @@ +// @vitest-pool threads +// ↑ vi.mock for external packages requires threads pool. + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockSsmSend = vi.fn(); +vi.mock("@aws-sdk/client-ssm", () => ({ + SSMClient: class { + send = mockSsmSend; + }, + GetParameterCommand: class { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, +})); + +import { loadAgentCoreConfig } from "./config.js"; + +describe("loadAgentCoreConfig", () => { + beforeEach(() => { + mockSsmSend.mockReset(); + }); + + // ── localOverride path ────────────────────────────────────────────── + + describe("localOverride path", () => { + it("returns override values directly and does NOT call SSM", async () => { + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + localOverride: { + runtimeArns: ["arn:aws:agentcore:us-west-2:123:runtime/local"], + memoryNamespacePrefix: "local_", + defaultModel: "anthropic.claude-haiku-3", + }, + }); + + expect(config.runtimeArns).toEqual(["arn:aws:agentcore:us-west-2:123:runtime/local"]); + expect(config.memoryNamespacePrefix).toBe("local_"); + expect(config.defaultModel).toBe("anthropic.claude-haiku-3"); + expect(mockSsmSend).not.toHaveBeenCalled(); + }); + + it("fills in defaults for missing fields", async () => { + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + localOverride: {}, + }); + + expect(config.runtimeArns).toEqual([]); + expect(config.memoryNamespacePrefix).toBe("tenant_"); + expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514"); + expect(config.region).toBe("us-west-2"); + expect(mockSsmSend).not.toHaveBeenCalled(); + }); + + it("preserves provided endpoint and invokeTimeoutMs", async () => { + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + region: "eu-west-1", + localOverride: { + endpoint: "https://localhost:9999", + invokeTimeoutMs: 60_000, + }, + }); + + expect(config.endpoint).toBe("https://localhost:9999"); + expect(config.invokeTimeoutMs).toBe(60_000); + expect(config.region).toBe("eu-west-1"); + }); + }); + + // ── SSM path ──────────────────────────────────────────────────────── + + describe("SSM path", () => { + it("parses runtime-arns as JSON array of strings", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/runtime-arns")) { + return { + Parameter: { + Value: + '["arn:aws:agentcore:us-west-2:123:runtime/a","arn:aws:agentcore:us-west-2:123:runtime/b"]', + }, + }; + } + return { Parameter: { Value: null } }; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.runtimeArns).toEqual([ + "arn:aws:agentcore:us-west-2:123:runtime/a", + "arn:aws:agentcore:us-west-2:123:runtime/b", + ]); + }); + + it("parses memory-config JSON and extracts memoryNamespacePrefix", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/memory-config")) { + return { + Parameter: { Value: '{"memoryEnabled":true,"memoryNamespacePrefix":"custom_prefix_"}' }, + }; + } + return { Parameter: { Value: null } }; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.memoryNamespacePrefix).toBe("custom_prefix_"); + }); + + it("uses default-model string value and trims whitespace", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/default-model")) { + return { Parameter: { Value: " anthropic.claude-haiku-3 " } }; + } + return { Parameter: { Value: null } }; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.defaultModel).toBe("anthropic.claude-haiku-3"); + }); + + it("falls back to defaults when SSM returns null for all params", async () => { + mockSsmSend.mockResolvedValue({ Parameter: { Value: null } }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.runtimeArns).toEqual([]); + expect(config.memoryNamespacePrefix).toBe("tenant_"); + expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514"); + expect(config.region).toBe("us-west-2"); + }); + + it("falls back to defaults when SSM params contain invalid JSON", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/runtime-arns")) { + return { Parameter: { Value: "not-valid-json{[" } }; + } + if (name?.endsWith("/memory-config")) { + return { Parameter: { Value: "{broken" } }; + } + if (name?.endsWith("/default-model")) { + return { Parameter: { Value: "" } }; + } + return {}; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.runtimeArns).toEqual([]); + expect(config.memoryNamespacePrefix).toBe("tenant_"); + expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514"); + }); + + it("uses DEFAULT_REGION when region not specified in source", async () => { + mockSsmSend.mockResolvedValue({ Parameter: { Value: null } }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.region).toBe("us-west-2"); + }); + }); + + // ── SSM error handling ────────────────────────────────────────────── + + describe("SSM error handling", () => { + it("handles SSM GetParameter failures gracefully and returns defaults", async () => { + mockSsmSend.mockRejectedValue(new Error("ParameterNotFound")); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.runtimeArns).toEqual([]); + expect(config.memoryNamespacePrefix).toBe("tenant_"); + expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514"); + expect(config.region).toBe("us-west-2"); + }); + + it("filters out empty and non-string entries from runtimeArns", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/runtime-arns")) { + return { + Parameter: { + Value: JSON.stringify([ + "arn:aws:agentcore:us-west-2:123:runtime/valid", + "", + " ", + 42, + null, + "arn:aws:agentcore:us-west-2:123:runtime/also-valid", + ]), + }, + }; + } + return { Parameter: { Value: null } }; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.runtimeArns).toEqual([ + "arn:aws:agentcore:us-west-2:123:runtime/valid", + "arn:aws:agentcore:us-west-2:123:runtime/also-valid", + ]); + }); + }); +}); diff --git a/extensions/agentcore/src/config.ts b/extensions/agentcore/src/config.ts new file mode 100644 index 0000000000000..159e150bc77da --- /dev/null +++ b/extensions/agentcore/src/config.ts @@ -0,0 +1,86 @@ +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; +import type { AgentCoreRuntimeConfig } from "./types.js"; + +const DEFAULT_REGION = "us-west-2"; +const DEFAULT_MEMORY_NAMESPACE_PREFIX = "tenant_"; +const DEFAULT_MODEL = "anthropic.claude-sonnet-4-20250514"; + +export type AgentCoreConfigSource = { + /** SSM parameter path prefix (e.g. "/hyperion/beta/agentcore"). */ + ssmPrefix: string; + /** AWS region for SSM and AgentCore. */ + region?: string; + /** Override for local development (skip SSM, use provided config). */ + localOverride?: Partial; +}; + +/** + * Load AgentCore config from SSM parameters written by CDK (AgentCoreConstruct): + * + * /hyperion/{stage}/agentcore/runtime-arns — JSON array of Runtime ARNs + * /hyperion/{stage}/agentcore/memory-config — JSON { memoryEnabled, memoryNamespacePrefix } + * /hyperion/{stage}/agentcore/default-model — model ID string + */ +export async function loadAgentCoreConfig( + source: AgentCoreConfigSource, +): Promise { + const region = source.region || DEFAULT_REGION; + + if (source.localOverride) { + return { + region, + runtimeArns: source.localOverride.runtimeArns ?? [], + memoryNamespacePrefix: + source.localOverride.memoryNamespacePrefix ?? DEFAULT_MEMORY_NAMESPACE_PREFIX, + defaultModel: source.localOverride.defaultModel ?? DEFAULT_MODEL, + endpoint: source.localOverride.endpoint, + invokeTimeoutMs: source.localOverride.invokeTimeoutMs, + }; + } + + const ssm = new SSMClient({ region }); + + const [runtimeArnsParam, memoryConfigParam, defaultModelParam] = await Promise.all([ + ssmGet(ssm, `${source.ssmPrefix}/runtime-arns`), + ssmGet(ssm, `${source.ssmPrefix}/memory-config`), + ssmGet(ssm, `${source.ssmPrefix}/default-model`), + ]); + + let runtimeArns: string[] = []; + try { + const parsed = JSON.parse(runtimeArnsParam ?? "[]"); + if (Array.isArray(parsed)) { + runtimeArns = parsed.filter((v): v is string => typeof v === "string" && v.trim() !== ""); + } + } catch { + // Fall through to empty array + } + + let memoryNamespacePrefix = DEFAULT_MEMORY_NAMESPACE_PREFIX; + try { + const parsed = JSON.parse(memoryConfigParam ?? "{}"); + if (parsed && typeof parsed.memoryNamespacePrefix === "string") { + memoryNamespacePrefix = parsed.memoryNamespacePrefix; + } + } catch { + // Fall through to default + } + + const defaultModel = defaultModelParam?.trim() || DEFAULT_MODEL; + + return { + region, + runtimeArns, + memoryNamespacePrefix, + defaultModel, + }; +} + +async function ssmGet(ssm: SSMClient, name: string): Promise { + try { + const resp = await ssm.send(new GetParameterCommand({ Name: name })); + return resp.Parameter?.Value ?? null; + } catch { + return null; + } +} diff --git a/extensions/agentcore/src/index.ts b/extensions/agentcore/src/index.ts new file mode 100644 index 0000000000000..d4a2fff1a8345 --- /dev/null +++ b/extensions/agentcore/src/index.ts @@ -0,0 +1,4 @@ +export { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.js"; +export { createAgentCoreRuntimeService, type CreateAgentCoreServiceParams } from "./service.js"; +export { loadAgentCoreConfig, type AgentCoreConfigSource } from "./config.js"; +export type { AgentCoreRuntimeConfig, AgentCoreHandleState } from "./types.js"; diff --git a/extensions/agentcore/src/runtime.test.ts b/extensions/agentcore/src/runtime.test.ts new file mode 100644 index 0000000000000..2aa7e6d5184be --- /dev/null +++ b/extensions/agentcore/src/runtime.test.ts @@ -0,0 +1,952 @@ +// @vitest-pool threads +// ↑ vi.mock for external packages requires threads pool (forks doesn't intercept). + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before importing the module under test. +// --------------------------------------------------------------------------- + +const mockSend = vi.fn(); + +vi.mock("@aws-sdk/client-bedrock-agentcore", () => ({ + BedrockAgentCoreClient: vi.fn().mockImplementation(function () { + return { send: mockSend }; + }), + InvokeAgentRuntimeCommand: vi.fn().mockImplementation(function (input: unknown) { + return { input }; + }), + StopRuntimeSessionCommand: vi.fn().mockImplementation(function (input: unknown) { + return { input }; + }), + RetrieveMemoryRecordsCommand: vi.fn().mockImplementation(function (input: unknown) { + return { input }; + }), + StartMemoryExtractionJobCommand: vi.fn().mockImplementation(function (input: unknown) { + return { input }; + }), +})); + +vi.mock("openclaw/plugin-sdk/acpx", () => { + class AcpRuntimeError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + this.name = "AcpRuntimeError"; + } + } + return { AcpRuntimeError }; +}); + +vi.mock("../../hyperion/src/globals.js", () => ({ + hasHyperionRuntime: vi.fn().mockReturnValue(false), + getHyperionRuntime: vi.fn(), +})); + +vi.mock("../../../src/hyperion/session-manager.js", () => ({ + extractTenantId: vi.fn((key: string) => { + if (!key.startsWith("tenant_")) return null; + const afterPrefix = key.slice(7); + const sep = afterPrefix.indexOf(":"); + if (sep < 0) return null; + return afterPrefix.slice(0, sep); + }), + extractAgentId: vi.fn((key: string) => { + if (!key.startsWith("tenant_")) return "main"; + const afterPrefix = key.slice(7); + const firstSep = afterPrefix.indexOf(":"); + if (firstSep < 0) return "main"; + const afterUserId = afterPrefix.slice(firstSep + 1); + const secondSep = afterUserId.indexOf(":"); + if (secondSep < 0) return afterUserId || "main"; + return afterUserId.slice(0, secondSep) || "main"; + }), +})); + +vi.mock("../../../src/hyperion/types.js", () => ({ + DEFAULT_AGENT_ID: "main", +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import type { + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeTurnInput, +} from "openclaw/plugin-sdk/acpx"; +import { hasHyperionRuntime, getHyperionRuntime } from "../../hyperion/src/globals.js"; +import { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.js"; +import type { AgentCoreRuntimeConfig, AgentCoreHandleState } from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const RUNTIME_ARN = "arn:aws:bedrock:us-east-1:123456789012:agent-runtime/test-runtime"; + +function makeConfig(overrides?: Partial): AgentCoreRuntimeConfig { + return { + region: "us-east-1", + runtimeArns: [RUNTIME_ARN], + memoryNamespacePrefix: "tenant_", + defaultModel: "anthropic.claude-sonnet-4-20250514", + ...overrides, + }; +} + +function createRuntime(overrides?: Partial): AgentCoreRuntime { + return new AgentCoreRuntime(makeConfig(overrides)); +} + +function makeEnsureInput(overrides?: Partial): AcpRuntimeEnsureInput { + return { + agent: "user1", + sessionKey: "tenant_user1:main:main", + mode: "persistent", + ...overrides, + }; +} + +function makeTurnInput( + handle: AcpRuntimeHandle, + overrides?: Partial, +): AcpRuntimeTurnInput { + return { + handle, + text: "Hi there", + mode: "prompt", + requestId: "test-request-id", + ...overrides, + }; +} + +/** Decode the base64url state from a handle's runtimeSessionName. */ +function decodeState(handle: AcpRuntimeHandle): AgentCoreHandleState { + const prefix = "agentcore:v1:"; + const encoded = handle.runtimeSessionName.slice(prefix.length); + return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); +} + +const SAMPLE_STATE: AgentCoreHandleState = { + runtimeArn: RUNTIME_ARN, + sessionId: "test-session-id", + tenantId: "user123", + agentId: "main", + agent: "user123", + mode: "persistent", +}; + +/** Collect all events from an async iterable. */ +async function collectEvents(iter: AsyncIterable): Promise { + const results: T[] = []; + for await (const item of iter) { + results.push(item); + } + return results; +} + +/** Narrow an AcpRuntimeEvent to the error variant. */ +function expectErrorEvent(event: AcpRuntimeEvent): AcpRuntimeEvent & { type: "error" } { + expect(event.type).toBe("error"); + return event as AcpRuntimeEvent & { type: "error" }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AGENTCORE_BACKEND_ID", () => { + it("equals 'agentcore'", () => { + expect(AGENTCORE_BACKEND_ID).toBe("agentcore"); + }); +}); + +describe("AgentCoreRuntime", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockReset(); + }); + + // ----------------------------------------------------------------------- + // ensureSession + // ----------------------------------------------------------------------- + + describe("ensureSession", () => { + it("throws AcpRuntimeError when agent is missing", async () => { + const runtime = createRuntime(); + const input = { + sessionKey: "tenant_u1:main:main", + mode: "persistent", + } as Partial; + await expect(runtime.ensureSession(input as AcpRuntimeEnsureInput)).rejects.toThrow( + "Agent ID is required.", + ); + }); + + it("throws AcpRuntimeError when agent is empty/whitespace", async () => { + const runtime = createRuntime(); + await expect(runtime.ensureSession(makeEnsureInput({ agent: " " }))).rejects.toThrow( + "Agent ID is required.", + ); + }); + + it("throws AcpRuntimeError when sessionKey is missing", async () => { + const runtime = createRuntime(); + const input = { agent: "user1", mode: "persistent" } as Partial; + await expect(runtime.ensureSession(input as AcpRuntimeEnsureInput)).rejects.toThrow( + "Session key is required.", + ); + }); + + it("throws AcpRuntimeError when sessionKey is empty/whitespace", async () => { + const runtime = createRuntime(); + await expect(runtime.ensureSession(makeEnsureInput({ sessionKey: " " }))).rejects.toThrow( + "Session key is required.", + ); + }); + + it("returns handle with correct sessionKey and backend", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + expect(handle.sessionKey).toBe("tenant_user1:main:main"); + expect(handle.backend).toBe("agentcore"); + }); + + it("returns handle with runtimeSessionName starting with 'agentcore:v1:'", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + expect(handle.runtimeSessionName).toMatch(/^agentcore:v1:/); + }); + + it("encodes handle state that roundtrips correctly", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + // Verify roundtrip: decode the encoded state and check fields + const state = decodeState(handle); + expect(state.runtimeArn).toBe(RUNTIME_ARN); + expect(state.tenantId).toBe("user1"); + expect(state.agent).toBe("user1"); + expect(state.mode).toBe("persistent"); + expect(state.sessionId).toBeTruthy(); + + // Also verify via getStatus (the public API roundtrip) + const status = await runtime.getStatus({ handle }); + expect(status.summary).toContain(`session=${state.sessionId}`); + expect(status.summary).toContain("tenant=user1"); + expect(status.backendSessionId).toBe(state.sessionId); + }); + + it("uses resumeSessionId when provided", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession( + makeEnsureInput({ resumeSessionId: "existing-session-id-123" }), + ); + + const state = decodeState(handle); + expect(state.sessionId).toBe("existing-session-id-123"); + expect(handle.backendSessionId).toBe("existing-session-id-123"); + }); + + it("generates new UUID sessionId when no resumeSessionId", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + const state = decodeState(handle); + // UUID v4 format: 8-4-4-4-12 hex chars + expect(state.sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("extracts agentId from session key", async () => { + const runtime = createRuntime(); + // Session key "tenant_user1:work:main" => agentId = "work" + const handle = await runtime.ensureSession( + makeEnsureInput({ sessionKey: "tenant_user1:work:main" }), + ); + + const state = decodeState(handle); + expect(state.agentId).toBe("work"); + }); + }); + + // ----------------------------------------------------------------------- + // cancel + // ----------------------------------------------------------------------- + + describe("cancel", () => { + it("calls StopRuntimeSessionCommand via client.send", async () => { + const runtime = createRuntime(); + mockSend.mockResolvedValue({}); + + const handle = await runtime.ensureSession(makeEnsureInput()); + await runtime.cancel({ handle }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const sentCommand = mockSend.mock.calls[0][0]; + expect(sentCommand.input.agentRuntimeArn).toBe(RUNTIME_ARN); + expect(sentCommand.input.runtimeSessionId).toBeTruthy(); + }); + + it("swallows errors (best-effort)", async () => { + const runtime = createRuntime(); + mockSend.mockRejectedValue(new Error("Network error")); + + const handle = await runtime.ensureSession(makeEnsureInput()); + + // Should not throw + await expect(runtime.cancel({ handle })).resolves.toBeUndefined(); + }); + }); + + // ----------------------------------------------------------------------- + // close + // ----------------------------------------------------------------------- + + describe("close", () => { + it("calls StopRuntimeSessionCommand for oneshot mode", async () => { + const runtime = createRuntime(); + mockSend.mockResolvedValue({}); + + const handle = await runtime.ensureSession(makeEnsureInput({ mode: "oneshot" })); + await runtime.close({ handle, reason: "done" }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const sentCommand = mockSend.mock.calls[0][0]; + expect(sentCommand.input.agentRuntimeArn).toBe(RUNTIME_ARN); + }); + + it("does NOT call StopRuntimeSessionCommand for persistent mode", async () => { + const runtime = createRuntime(); + + const handle = await runtime.ensureSession(makeEnsureInput()); + await runtime.close({ handle, reason: "done" }); + + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // doctor + // ----------------------------------------------------------------------- + + describe("doctor", () => { + it("returns ok:false when no runtimeArns configured", async () => { + const runtime = createRuntime({ runtimeArns: [] }); + + const report = await runtime.doctor(); + + expect(report.ok).toBe(false); + expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE"); + expect(report.message).toContain("No AgentCore Runtime ARNs configured"); + }); + + it("returns ok:true with message when runtimeArns are present", async () => { + const runtime = createRuntime(); + + const report = await runtime.doctor(); + + expect(report.ok).toBe(true); + expect(report.message).toContain("AgentCore backend configured"); + expect(report.message).toContain("us-east-1"); + expect(report.message).toContain("runtimes: 1"); + }); + }); + + // ----------------------------------------------------------------------- + // getCapabilities + // ----------------------------------------------------------------------- + + describe("getCapabilities", () => { + it("returns capabilities with empty controls array", () => { + const runtime = createRuntime(); + const caps = runtime.getCapabilities(); + expect(caps).toEqual({ controls: [] }); + }); + }); + + // ----------------------------------------------------------------------- + // getStatus + // ----------------------------------------------------------------------- + + describe("getStatus", () => { + it("returns summary with session, runtime, and tenant info", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput({ resumeSessionId: "sess-abc" })); + + const status = await runtime.getStatus({ handle }); + + expect(status.summary).toContain("session=sess-abc"); + expect(status.summary).toContain(`runtime=${RUNTIME_ARN}`); + expect(status.summary).toContain("tenant=user1"); + expect(status.backendSessionId).toBe("sess-abc"); + }); + }); + + // ----------------------------------------------------------------------- + // isHealthy / setHealthy + // ----------------------------------------------------------------------- + + describe("isHealthy / setHealthy", () => { + it("is initially false", () => { + const runtime = createRuntime(); + expect(runtime.isHealthy()).toBe(false); + }); + + it("setHealthy(true) makes it true", () => { + const runtime = createRuntime(); + runtime.setHealthy(true); + expect(runtime.isHealthy()).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // resolveHandleState (tested indirectly via getStatus) + // ----------------------------------------------------------------------- + + describe("resolveHandleState (indirect)", () => { + it("throws AcpRuntimeError for handle with wrong prefix", async () => { + const runtime = createRuntime(); + const badHandle: AcpRuntimeHandle = { + sessionKey: "test", + backend: "agentcore", + runtimeSessionName: "wrong-prefix:eyJ0ZXN0IjoxfQ", + }; + + await expect(runtime.getStatus({ handle: badHandle })).rejects.toThrow( + "Invalid AgentCore runtime handle", + ); + }); + + it("throws AcpRuntimeError for handle with corrupted base64", async () => { + const runtime = createRuntime(); + const badHandle: AcpRuntimeHandle = { + sessionKey: "test", + backend: "agentcore", + runtimeSessionName: "agentcore:v1:!!!not-valid-base64!!!", + }; + + await expect(runtime.getStatus({ handle: badHandle })).rejects.toThrow("could not decode"); + }); + }); + + // ----------------------------------------------------------------------- + // pickRuntimeArn (tested indirectly via ensureSession) + // ----------------------------------------------------------------------- + + describe("pickRuntimeArn (indirect)", () => { + it("throws when runtimeArns is empty", async () => { + const runtime = createRuntime({ runtimeArns: [] }); + + await expect(runtime.ensureSession(makeEnsureInput())).rejects.toThrow( + "No AgentCore Runtime ARNs configured", + ); + }); + + it("selects from multiple ARNs without error", async () => { + const arns = [ + "arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-1", + "arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-2", + ]; + const runtime = createRuntime({ runtimeArns: arns }); + + const handle = await runtime.ensureSession(makeEnsureInput()); + + const state = decodeState(handle); + expect(arns).toContain(state.runtimeArn); + }); + }); + + // ----------------------------------------------------------------------- + // runTurn + // ----------------------------------------------------------------------- + + describe("runTurn", () => { + it("yields text_delta and done events for a successful invocation", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Hello, user!" }), + }, + }) // InvokeAgentRuntime + .mockResolvedValueOnce({}); // StartMemoryExtractionJob + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ + type: "text_delta", + text: "Hello, user!", + stream: "output", + }); + expect(events[1]).toEqual({ type: "done" }); + }); + + it("yields done event when response body is empty", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { transformToString: async () => " " }, + }); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ type: "done" }); + }); + + it("yields error event on invocation failure", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockRejectedValueOnce(new Error("Connection refused")); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events).toHaveLength(1); + const errEvent = expectErrorEvent(events[0]); + expect(errEvent.message).toContain("Connection refused"); + }); + + it("yields retryable error on ThrottlingException", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + const throttleErr = new Error("Rate exceeded"); + throttleErr.name = "ThrottlingException"; + + mockSend.mockResolvedValueOnce({ records: [] }).mockRejectedValueOnce(throttleErr); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events).toHaveLength(1); + const errEvent = expectErrorEvent(events[0]); + expect(errEvent.code).toBe("RATE_LIMITED"); + expect(errEvent.retryable).toBe(true); + }); + + it("sets healthy=false on ResourceNotFoundException", async () => { + const runtime = createRuntime(); + runtime.setHealthy(true); + expect(runtime.isHealthy()).toBe(true); + + const handle = await runtime.ensureSession(makeEnsureInput()); + + const notFoundErr = new Error("Runtime not found"); + notFoundErr.name = "ResourceNotFoundException"; + + mockSend.mockResolvedValueOnce({ records: [] }).mockRejectedValueOnce(notFoundErr); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + const errEvent = expectErrorEvent(events[0]); + expect(errEvent.code).toBe("RESOURCE_NOT_FOUND"); + expect(runtime.isHealthy()).toBe(false); + }); + + it("silently returns when signal is aborted during invocation", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + const abortController = new AbortController(); + abortController.abort(); + + mockSend + .mockResolvedValueOnce({ records: [] }) + .mockRejectedValueOnce(new DOMException("Aborted", "AbortError")); + + const events = await collectEvents( + runtime.runTurn(makeTurnInput(handle, { signal: abortController.signal })), + ); + + expect(events).toHaveLength(0); + }); + + it("loads tenant context when Hyperion runtime is available", async () => { + const mockDbClient = { + getTenantConfig: vi.fn().mockResolvedValue({ + user_id: "user1", + display_name: "Test User", + model: "anthropic.claude-sonnet-4-20250514", + custom_instructions: "Be helpful", + tools: [], + profile: {}, + plan: "pro", + }), + }; + + vi.mocked(hasHyperionRuntime).mockReturnValue(true); + vi.mocked(getHyperionRuntime).mockReturnValue({ dbClient: mockDbClient } as ReturnType< + typeof getHyperionRuntime + >); + + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Hi!" }), + }, + }) // InvokeAgentRuntime + .mockResolvedValueOnce({}); // extractMemory + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle, { text: "Hello" }))); + + expect(mockDbClient.getTenantConfig).toHaveBeenCalledWith("user1", "main"); + expect(events[0].type).toBe("text_delta"); + }); + + it("includes memory records in invocation payload when available", async () => { + vi.mocked(hasHyperionRuntime).mockReturnValue(false); + + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ + records: [ + { content: { text: "User likes coffee" }, score: 0.95 }, + { content: { text: "User is a developer" }, score: 0.88 }, + ], + }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Got it!" }), + }, + }) // InvokeAgentRuntime + .mockResolvedValueOnce({}); // extractMemory + + await collectEvents(runtime.runTurn(makeTurnInput(handle, { text: "Tell me about myself" }))); + + // Verify InvokeAgentRuntimeCommand was called (second send call) + expect(mockSend).toHaveBeenCalledTimes(3); + const invokeCall = mockSend.mock.calls[1][0]; + const payload = JSON.parse(new TextDecoder().decode(invokeCall.input.payload)); + expect(payload.memory_context).toHaveLength(2); + expect(payload.memory_context[0].content).toBe("User likes coffee"); + }); + + it("fires memory extraction after a turn with response text", async () => { + vi.mocked(hasHyperionRuntime).mockReturnValue(false); + + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Memory-worthy response" }), + }, + }) // InvokeAgentRuntime + .mockResolvedValueOnce({}); // extractMemory + + await collectEvents(runtime.runTurn(makeTurnInput(handle, { text: "Important message" }))); + + // Wait a tick for fire-and-forget to execute + await new Promise((r) => setTimeout(r, 10)); + + // Third send call should be StartMemoryExtractionJobCommand + expect(mockSend).toHaveBeenCalledTimes(3); + const extractCall = mockSend.mock.calls[2][0]; + expect(extractCall.input.namespace).toBe("tenant_user1:main"); + expect(extractCall.input.content.text).toContain("Important message"); + expect(extractCall.input.content.text).toContain("Memory-worthy response"); + }); + + it("uses correct memory namespace with agentId from session key", async () => { + vi.mocked(hasHyperionRuntime).mockReturnValue(false); + + const runtime = createRuntime(); + // Session key with agentId "work" + const handle = await runtime.ensureSession( + makeEnsureInput({ sessionKey: "tenant_user1:work:slack:U111" }), + ); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Reply" }), + }, + }) + .mockResolvedValueOnce({}); // extractMemory + + await collectEvents(runtime.runTurn(makeTurnInput(handle, { text: "test" }))); + + await new Promise((r) => setTimeout(r, 10)); + + // Memory namespace should be "tenant_user1:work" + const extractCall = mockSend.mock.calls[2][0]; + expect(extractCall.input.namespace).toBe("tenant_user1:work"); + }); + + it("handles non-JSON response body as raw text", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) + .mockResolvedValueOnce({ + response: { + transformToString: async () => "Plain text response", + }, + }) + .mockResolvedValueOnce({}); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events[0]).toEqual({ + type: "text_delta", + text: "Plain text response", + stream: "output", + }); + }); + + it("handles response with no response body (yields done)", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend.mockResolvedValueOnce({ records: [] }).mockResolvedValueOnce({ + response: undefined, + }); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events).toEqual([{ type: "done" }]); + }); + + it("emits retryable error for 5xx status code", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend.mockResolvedValueOnce({ records: [] }).mockResolvedValueOnce({ + response: { transformToString: async () => "error" }, + statusCode: 503, + }); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events[0]).toMatchObject({ + type: "error", + retryable: true, + }); + const errEvent = expectErrorEvent(events[0]); + expect(errEvent.message).toContain("503"); + }); + + it("emits non-retryable error for 4xx status code", async () => { + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend.mockResolvedValueOnce({ records: [] }).mockResolvedValueOnce({ + response: { transformToString: async () => "error" }, + statusCode: 400, + }); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events[0]).toMatchObject({ + type: "error", + retryable: false, + }); + }); + + it("continues without tenant context when Hyperion runtime throws", async () => { + vi.mocked(hasHyperionRuntime).mockReturnValue(true); + vi.mocked(getHyperionRuntime).mockReturnValue({ + dbClient: { + getTenantConfig: vi.fn().mockRejectedValue(new Error("DDB timeout")), + }, + } as ReturnType); + + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockResolvedValueOnce({ records: [] }) // retrieveMemory + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Still works" }), + }, + }) + .mockResolvedValueOnce({}); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + // Should still get a response despite tenant context failure + expect(events[0]).toMatchObject({ type: "text_delta", text: "Still works" }); + }); + + it("continues without memory when retrieveMemory throws", async () => { + vi.mocked(hasHyperionRuntime).mockReturnValue(false); + + const runtime = createRuntime(); + const handle = await runtime.ensureSession(makeEnsureInput()); + + mockSend + .mockRejectedValueOnce(new Error("Memory service down")) // retrieveMemory fails + .mockResolvedValueOnce({ + response: { + transformToString: async () => JSON.stringify({ response: "Works anyway" }), + }, + }) + .mockResolvedValueOnce({}); + + const events = await collectEvents(runtime.runTurn(makeTurnInput(handle))); + + expect(events[0]).toMatchObject({ type: "text_delta", text: "Works anyway" }); + }); + }); + + // ----------------------------------------------------------------------- + // handleInvocationError — error classification (via private access) + // ----------------------------------------------------------------------- + + describe("handleInvocationError (error classification)", () => { + function collectErrors( + runtime: AgentCoreRuntime, + err: unknown, + ): Array<{ type: string; code?: string; message?: string; retryable?: boolean }> { + const events: Array<{ type: string; code?: string; message?: string; retryable?: boolean }> = + []; + // Access private method via bracket notation for testing + for (const event of runtime["handleInvocationError"](err)) { + events.push(event); + } + return events; + } + + it("classifies ThrottlingException as RATE_LIMITED and retryable", () => { + const runtime = createRuntime(); + const err = new Error("Rate exceeded"); + err.name = "ThrottlingException"; + + const events = collectErrors(runtime, err); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + code: "RATE_LIMITED", + retryable: true, + }); + }); + + it("classifies 'Too Many Requests' message as RATE_LIMITED", () => { + const runtime = createRuntime(); + const events = collectErrors(runtime, new Error("Too Many Requests")); + expect(events[0]).toMatchObject({ code: "RATE_LIMITED", retryable: true }); + }); + + it("classifies ServiceUnavailableException as SERVICE_UNAVAILABLE", () => { + const runtime = createRuntime(); + const err = new Error("Service unavailable"); + err.name = "ServiceUnavailableException"; + + const events = collectErrors(runtime, err); + expect(events[0]).toMatchObject({ code: "SERVICE_UNAVAILABLE", retryable: true }); + }); + + it("classifies ResourceNotFoundException and sets unhealthy", () => { + const runtime = createRuntime(); + runtime.setHealthy(true); + const err = new Error("Not found"); + err.name = "ResourceNotFoundException"; + + const events = collectErrors(runtime, err); + expect(events[0]).toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + expect(runtime.isHealthy()).toBe(false); + }); + + it("classifies unknown errors as generic (no code, not retryable)", () => { + const runtime = createRuntime(); + const events = collectErrors(runtime, new Error("Something unexpected")); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + expect(events[0].code).toBeUndefined(); + expect(events[0].retryable).toBeUndefined(); + expect(events[0].message).toContain("Something unexpected"); + }); + }); + + // ----------------------------------------------------------------------- + // processResponse (via private access) + // ----------------------------------------------------------------------- + + describe("processResponse (indirect)", () => { + interface MockResponse { + response?: { transformToString: () => Promise }; + statusCode?: number; + } + + async function collectResponseEvents( + runtime: AgentCoreRuntime, + response: MockResponse, + state: AgentCoreHandleState, + ) { + return collectEvents(runtime["processResponse"](response, state)); + } + + it("parses JSON with 'text' field", async () => { + const runtime = createRuntime(); + const events = await collectResponseEvents( + runtime, + { response: { transformToString: async () => JSON.stringify({ text: "Text reply" }) } }, + SAMPLE_STATE, + ); + expect(events).toEqual([ + { type: "text_delta", text: "Text reply", stream: "output" }, + { type: "done" }, + ]); + }); + + it("parses JSON with 'message' field", async () => { + const runtime = createRuntime(); + const events = await collectResponseEvents( + runtime, + { response: { transformToString: async () => JSON.stringify({ message: "Msg reply" }) } }, + SAMPLE_STATE, + ); + expect(events).toEqual([ + { type: "text_delta", text: "Msg reply", stream: "output" }, + { type: "done" }, + ]); + }); + + it("emits error when transformToString throws", async () => { + const runtime = createRuntime(); + const events = await collectResponseEvents( + runtime, + { + response: { + transformToString: async () => { + throw new Error("Stream interrupted"); + }, + }, + }, + SAMPLE_STATE, + ); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Stream interrupted"), + }); + }); + }); +}); diff --git a/extensions/agentcore/src/runtime.ts b/extensions/agentcore/src/runtime.ts new file mode 100644 index 0000000000000..8ecfeea69d10f --- /dev/null +++ b/extensions/agentcore/src/runtime.ts @@ -0,0 +1,509 @@ +import crypto from "node:crypto"; +import { + BedrockAgentCoreClient, + InvokeAgentRuntimeCommand, + StopRuntimeSessionCommand, + RetrieveMemoryRecordsCommand, + StartMemoryExtractionJobCommand, +} from "@aws-sdk/client-bedrock-agentcore"; +import type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, +} from "openclaw/plugin-sdk/acpx"; +import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; +import { extractTenantId, extractAgentId } from "../../../src/hyperion/session-manager.js"; +import { DEFAULT_AGENT_ID } from "../../../src/hyperion/types.js"; +import { hasHyperionRuntime, getHyperionRuntime } from "../../hyperion/src/globals.js"; +import type { AgentCoreHandleState, AgentCoreRuntimeConfig } from "./types.js"; + +export const AGENTCORE_BACKEND_ID = "agentcore"; + +const HANDLE_PREFIX = "agentcore:v1:"; +const DEFAULT_INVOKE_TIMEOUT_MS = 300_000; // 5 minutes + +const AGENTCORE_CAPABILITIES: AcpRuntimeCapabilities = { + // AgentCore doesn't expose OC-style session controls + controls: [], +}; + +// --------------------------------------------------------------------------- +// Handle state encoding (persisted in AcpRuntimeHandle.runtimeSessionName) +// --------------------------------------------------------------------------- + +function encodeHandleState(state: AgentCoreHandleState): string { + return `${HANDLE_PREFIX}${Buffer.from(JSON.stringify(state), "utf8").toString("base64url")}`; +} + +function decodeHandleState(runtimeSessionName: string): AgentCoreHandleState | null { + if (!runtimeSessionName.startsWith(HANDLE_PREFIX)) { + return null; + } + try { + const encoded = runtimeSessionName.slice(HANDLE_PREFIX.length); + return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as AgentCoreHandleState; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Runtime ARN selection +// --------------------------------------------------------------------------- + +/** + * Pick a runtime ARN from the configured pool. + * Simple random selection; can be replaced with health-aware routing. + */ +function pickRuntimeArn(arns: string[]): string { + if (arns.length === 0) { + throw new AcpRuntimeError("ACP_BACKEND_UNAVAILABLE", "No AgentCore Runtime ARNs configured."); + } + if (arns.length === 1) { + return arns[0]!; + } + return arns[Math.floor(Math.random() * arns.length)]!; +} + +// --------------------------------------------------------------------------- +// AgentCore AcpRuntime implementation +// --------------------------------------------------------------------------- + +export class AgentCoreRuntime implements AcpRuntime { + private healthy = false; + private readonly client: BedrockAgentCoreClient; + private readonly config: AgentCoreRuntimeConfig; + + constructor(config: AgentCoreRuntimeConfig) { + this.config = config; + this.client = new BedrockAgentCoreClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); + } + + isHealthy(): boolean { + return this.healthy; + } + + setHealthy(value: boolean): void { + this.healthy = value; + } + + // ------------------------------------------------------------------------- + // ensureSession — creates session state for subsequent runTurn calls + // ------------------------------------------------------------------------- + + async ensureSession(input: AcpRuntimeEnsureInput): Promise { + const agent = input.agent?.trim(); + if (!agent) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Agent ID is required."); + } + const sessionKey = input.sessionKey?.trim(); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Session key is required."); + } + + const runtimeArn = pickRuntimeArn(this.config.runtimeArns); + + // For Hyperion, the OC agentId IS the tenant user_id. + const tenantId = agent; + // [claude-infra] Multi-instance: extract agent instance ID from session key. + const agentId = extractAgentId(sessionKey); + + // AgentCore sessions are created implicitly on first InvokeAgentRuntime. + // We generate a stable session ID here. For resumed sessions, reuse the + // provided ID so AgentCore picks up the existing conversation. + const sessionId = input.resumeSessionId || crypto.randomUUID(); + + const state: AgentCoreHandleState = { + runtimeArn, + sessionId, + tenantId, + agentId, + agent, + mode: input.mode, + }; + + return { + sessionKey, + backend: AGENTCORE_BACKEND_ID, + runtimeSessionName: encodeHandleState(state), + cwd: input.cwd, + backendSessionId: sessionId, + }; + } + + // ------------------------------------------------------------------------- + // runTurn — invokes AgentCore and streams AcpRuntimeEvents back + // ------------------------------------------------------------------------- + + async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable { + const state = this.resolveHandleState(input.handle); + // [claude-infra] Multi-instance: memory namespace includes agentId for isolation. + // Uses configurable prefix (memoryNamespacePrefix from SSM) rather than + // buildTenantMemoryNamespace() which hardcodes "tenant_". + const memoryNamespace = `${this.config.memoryNamespacePrefix}${state.tenantId}:${state.agentId}`; + + // Load tenant config and retrieve memory in parallel. + // Tenant config provides model, tools, custom_instructions, profile. + // Memory provides prior conversation context for the agent. + const [tenantContext, memoryRecords] = await Promise.all([ + this.loadTenantContext(state.tenantId, state.agentId), + this.retrieveMemory(memoryNamespace, input.text), + ]); + + const payload: Record = { + sessionId: state.sessionId, + tenant_id: state.tenantId, + message: input.text, + ...(tenantContext ? { tenant_config: tenantContext } : {}), + ...(memoryRecords.length > 0 ? { memory_context: memoryRecords } : {}), + }; + + const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)); + + let response; + try { + response = await this.client.send( + new InvokeAgentRuntimeCommand({ + agentRuntimeArn: state.runtimeArn, + runtimeSessionId: state.sessionId, + runtimeUserId: state.tenantId, + contentType: "application/json", + accept: "application/json", + payload: payloadBytes, + }), + { + abortSignal: input.signal, + requestTimeout: this.config.invokeTimeoutMs ?? DEFAULT_INVOKE_TIMEOUT_MS, + }, + ); + } catch (err) { + if (input.signal?.aborted) { + return; + } + yield* this.handleInvocationError(err); + return; + } + + // Process the response stream and collect the full text for memory extraction. + let fullResponseText = ""; + for await (const event of this.processResponse(response, state)) { + if (event.type === "text_delta" && event.text) { + fullResponseText += event.text; + } + yield event; + } + + // Fire-and-forget: extract memories from this turn for future context. + if (fullResponseText) { + void this.extractMemory(memoryNamespace, input.text, fullResponseText); + } + } + + // ------------------------------------------------------------------------- + // cancel / close — session lifecycle + // ------------------------------------------------------------------------- + + async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise { + // Best-effort: stop the runtime session so AgentCore tears down the microVM. + const state = this.resolveHandleState(input.handle); + try { + await this.client.send( + new StopRuntimeSessionCommand({ + agentRuntimeArn: state.runtimeArn, + runtimeSessionId: state.sessionId, + }), + ); + } catch { + // Swallow errors — cancel is best-effort + } + } + + async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise { + // For oneshot sessions, stop the runtime session. + // For persistent sessions, leave it alive for future turns. + const state = this.resolveHandleState(input.handle); + if (state.mode === "oneshot") { + try { + await this.client.send( + new StopRuntimeSessionCommand({ + agentRuntimeArn: state.runtimeArn, + runtimeSessionId: state.sessionId, + }), + ); + } catch { + // Best-effort cleanup + } + } + } + + // ------------------------------------------------------------------------- + // getCapabilities / getStatus / doctor + // ------------------------------------------------------------------------- + + getCapabilities(): AcpRuntimeCapabilities { + return AGENTCORE_CAPABILITIES; + } + + async getStatus(input: { + handle: AcpRuntimeHandle; + signal?: AbortSignal; + }): Promise { + const state = this.resolveHandleState(input.handle); + return { + summary: `agentcore session=${state.sessionId} runtime=${state.runtimeArn} tenant=${state.tenantId}`, + backendSessionId: state.sessionId, + }; + } + + async doctor(): Promise { + if (this.config.runtimeArns.length === 0) { + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: + "No AgentCore Runtime ARNs configured. " + + "Populate SSM parameter /hyperion/{stage}/agentcore/runtime-arns.", + }; + } + + // Lightweight check: try to describe the first runtime + try { + // TODO: replace with a proper health ping when AgentCore exposes one. + // For now, we just validate config is present. + return { + ok: true, + message: + `AgentCore backend configured ` + + `(region: ${this.config.region}, runtimes: ${this.config.runtimeArns.length})`, + }; + } catch (err) { + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: err instanceof Error ? err.message : String(err), + }; + } + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Load tenant config from the Hyperion runtime (DynamoDB). + * Returns a subset of the config relevant to the agent container: + * model, custom_instructions, tools, profile, display_name. + */ + // [claude-infra] Multi-instance: loads config for specific agent instance. + private async loadTenantContext( + tenantId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise | null> { + if (!hasHyperionRuntime()) return null; + try { + const runtime = getHyperionRuntime(); + const tenantConfig = await runtime.dbClient.getTenantConfig(tenantId, agentId); + if (!tenantConfig) return null; + return { + user_id: tenantConfig.user_id, + display_name: tenantConfig.display_name, + model: tenantConfig.model ?? this.config.defaultModel, + custom_instructions: tenantConfig.custom_instructions, + tools: tenantConfig.tools, + profile: tenantConfig.profile, + plan: tenantConfig.plan, + }; + } catch { + // Non-fatal: agent can still run without tenant context + return null; + } + } + + /** + * Retrieve relevant memory records for this tenant before the turn. + * Uses the user's message as a semantic query to find related memories. + */ + private async retrieveMemory( + namespace: string, + query: string, + ): Promise> { + try { + const resp = await this.client.send( + new RetrieveMemoryRecordsCommand({ + namespace, + query: { text: query }, + maxResults: 10, + }), + ); + if (!resp.records || resp.records.length === 0) return []; + return resp.records.map((r) => ({ + content: r.content?.text ?? "", + score: r.score, + })); + } catch { + // Non-fatal: agent runs without memory context on failure + return []; + } + } + + /** + * Extract and persist memories from a completed turn (fire-and-forget). + * AgentCore Memory will extract salient facts from the conversation. + */ + private async extractMemory( + namespace: string, + userMessage: string, + agentResponse: string, + ): Promise { + try { + await this.client.send( + new StartMemoryExtractionJobCommand({ + namespace, + content: { + text: `User: ${userMessage}\nAssistant: ${agentResponse}`, + }, + }), + ); + } catch { + // Best-effort: memory extraction failure is not user-facing + } + } + + private resolveHandleState(handle: AcpRuntimeHandle): AgentCoreHandleState { + const state = decodeHandleState(handle.runtimeSessionName); + if (!state) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + "Invalid AgentCore runtime handle: could not decode state.", + ); + } + return state; + } + + /** + * Process the InvokeAgentRuntime response. + * + * The response.response is a StreamingBlob — we consume it as text and + * parse the agent's output. The agent container returns JSON with a + * "response" field containing the agent's reply text. + * + * For streaming: the response blob may arrive in chunks. We emit + * text_delta events as data arrives, then a done event at the end. + */ + private async *processResponse( + response: { + response?: { transformToString(): Promise } | undefined; + runtimeSessionId?: string; + statusCode?: number; + }, + state: AgentCoreHandleState, + ): AsyncIterable { + if (!response.response) { + yield { type: "done" }; + return; + } + + if (response.statusCode && response.statusCode >= 400) { + yield { + type: "error", + message: `AgentCore returned status ${response.statusCode}`, + retryable: response.statusCode >= 500, + }; + return; + } + + try { + const body = await response.response.transformToString(); + + if (!body.trim()) { + yield { type: "done" }; + return; + } + + // Try to parse as JSON (agent container format: { response: "..." }) + let text: string; + try { + const parsed = JSON.parse(body); + text = + typeof parsed.response === "string" + ? parsed.response + : typeof parsed.text === "string" + ? parsed.text + : typeof parsed.message === "string" + ? parsed.message + : body; + } catch { + // Not JSON — treat the raw body as the agent's text response + text = body; + } + + if (text) { + yield { + type: "text_delta", + text, + stream: "output", + }; + } + + yield { type: "done" }; + } catch (err) { + yield { + type: "error", + message: `Failed to read AgentCore response: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + /** + * Map AgentCore invocation errors to AcpRuntimeEvents. + */ + private *handleInvocationError(err: unknown): Iterable { + const message = err instanceof Error ? err.message : String(err); + const errName = err instanceof Error ? err.name : ""; + + const isThrottled = + errName === "ThrottlingException" || + message.includes("ThrottlingException") || + message.includes("Too Many Requests"); + const isTransient = + errName === "ServiceUnavailableException" || + errName === "InternalServerException" || + message.includes("ServiceUnavailable") || + message.includes("InternalServer"); + const isNotFound = + errName === "ResourceNotFoundException" || message.includes("ResourceNotFound"); + + if (isNotFound) { + this.healthy = false; + yield { + type: "error", + message: `AgentCore Runtime not found. Check runtime ARN configuration. ${message}`, + code: "RESOURCE_NOT_FOUND", + }; + return; + } + + if (isThrottled || isTransient) { + yield { + type: "error", + message: `AgentCore invocation failed: ${message}`, + code: isThrottled ? "RATE_LIMITED" : "SERVICE_UNAVAILABLE", + retryable: true, + }; + return; + } + + yield { + type: "error", + message: `AgentCore invocation failed: ${message}`, + }; + } +} diff --git a/extensions/agentcore/src/service.ts b/extensions/agentcore/src/service.ts new file mode 100644 index 0000000000000..9bbfd2f7db107 --- /dev/null +++ b/extensions/agentcore/src/service.ts @@ -0,0 +1,90 @@ +import type { + AcpRuntime, + OpenClawPluginService, + OpenClawPluginServiceContext, +} from "openclaw/plugin-sdk/acpx"; +import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk/acpx"; +import { loadAgentCoreConfig, type AgentCoreConfigSource } from "./config.js"; +import { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.js"; + +type AgentCoreRuntimeLike = AcpRuntime & { + isHealthy(): boolean; + setHealthy(value: boolean): void; + doctor(): Promise<{ ok: boolean; message: string }>; +}; + +export type CreateAgentCoreServiceParams = { + configSource: AgentCoreConfigSource; +}; + +/** + * OpenClaw plugin service that registers the AgentCore ACP backend. + * + * Usage in gateway startup: + * const service = createAgentCoreRuntimeService({ + * configSource: { + * ssmPrefix: `/hyperion/${stage}/agentcore`, + * region: "us-west-2", + * }, + * }); + * await service.start(ctx); + * + * This registers "agentcore" as an ACP runtime backend. When OC dispatches + * a message via ACP (e.g. from external channel webhooks), it flows through + * AgentCoreRuntime.runTurn() which invokes Bedrock AgentCore. + */ +export function createAgentCoreRuntimeService( + params: CreateAgentCoreServiceParams, +): OpenClawPluginService { + let runtime: AgentCoreRuntimeLike | null = null; + + return { + id: "agentcore-runtime", + + async start(ctx: OpenClawPluginServiceContext): Promise { + const config = await loadAgentCoreConfig(params.configSource); + + if (config.runtimeArns.length === 0) { + ctx.logger.warn( + "AgentCore runtime backend has no runtime ARNs configured. " + + "Backend will be registered but unhealthy until SSM parameter is populated.", + ); + } + + runtime = new AgentCoreRuntime(config); + + registerAcpRuntimeBackend({ + id: AGENTCORE_BACKEND_ID, + runtime, + healthy: () => runtime?.isHealthy() ?? false, + }); + + ctx.logger.info( + `AgentCore runtime backend registered (region: ${config.region}, ` + + `runtimes: ${config.runtimeArns.length}, model: ${config.defaultModel})`, + ); + + // Probe health in background + void (async () => { + try { + const report = await runtime?.doctor(); + if (report?.ok) { + runtime?.setHealthy(true); + ctx.logger.info("AgentCore runtime backend ready"); + } else { + ctx.logger.warn(`AgentCore runtime backend probe failed: ${report?.message}`); + } + } catch (err) { + ctx.logger.warn( + `AgentCore runtime health check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(); + }, + + async stop(_ctx: OpenClawPluginServiceContext): Promise { + unregisterAcpRuntimeBackend(AGENTCORE_BACKEND_ID); + runtime = null; + }, + }; +} diff --git a/extensions/agentcore/src/types.ts b/extensions/agentcore/src/types.ts new file mode 100644 index 0000000000000..ecbe8808d6f0c --- /dev/null +++ b/extensions/agentcore/src/types.ts @@ -0,0 +1,37 @@ +/** + * Configuration for the AgentCore runtime backend. + * Loaded from SSM parameters at startup (see CDK AgentCoreConstruct). + */ +export type AgentCoreRuntimeConfig = { + /** AWS region for AgentCore API calls. */ + region: string; + /** AgentCore Runtime ARNs for load distribution. */ + runtimeArns: string[]; + /** Prefix for per-tenant memory namespacing (e.g. "tenant_"). */ + memoryNamespacePrefix: string; + /** Default Bedrock model ID (e.g. "anthropic.claude-sonnet-4-20250514"). */ + defaultModel: string; + /** AgentCore endpoint override (for local testing). */ + endpoint?: string; + /** Timeout for agent invocations in milliseconds. Default 300_000 (5 min). */ + invokeTimeoutMs?: number; +}; + +/** + * Internal handle state encoded into AcpRuntimeHandle.runtimeSessionName. + * Tracks the AgentCore session identity for subsequent turns. + */ +export type AgentCoreHandleState = { + /** AgentCore Runtime ARN used for this session. */ + runtimeArn: string; + /** Session ID passed as runtimeSessionId to AgentCore. */ + sessionId: string; + /** Tenant/user ID — used as runtimeUserId for per-tenant isolation. */ + tenantId: string; + /** Agent instance ID within the tenant. Default: "main". [claude-infra] */ + agentId: string; + /** Agent identifier from OC's session key. */ + agent: string; + /** Session mode. */ + mode: "persistent" | "oneshot"; +}; diff --git a/extensions/hyperion/index.ts b/extensions/hyperion/index.ts new file mode 100644 index 0000000000000..cf2021c920b73 --- /dev/null +++ b/extensions/hyperion/index.ts @@ -0,0 +1,15 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { createHyperionPluginService, type HyperionPluginConfig } from "./src/service.js"; + +const plugin = { + id: "hyperion", + name: "Hyperion Multi-Tenant Runtime", + description: "Multi-tenant DynamoDB integration for the Nova Personal Assistant Platform.", + register(api: OpenClawPluginApi) { + const pluginConfig = (api.pluginConfig ?? {}) as HyperionPluginConfig; + + api.registerService(createHyperionPluginService(pluginConfig)); + }, +}; + +export default plugin; diff --git a/extensions/hyperion/openclaw.plugin.json b/extensions/hyperion/openclaw.plugin.json new file mode 100644 index 0000000000000..73aeac442a4e8 --- /dev/null +++ b/extensions/hyperion/openclaw.plugin.json @@ -0,0 +1,24 @@ +{ + "id": "hyperion", + "name": "Hyperion Multi-Tenant Runtime", + "description": "Multi-tenant DynamoDB integration for the Nova Personal Assistant Platform. Provides per-tenant config loading, channel identity resolution, credential encryption, and pairing.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "stage": { + "type": "string", + "enum": ["beta", "gamma", "prod"], + "description": "Deployment stage (derives table names and KMS key alias)" + }, + "region": { + "type": "string", + "description": "AWS region for DynamoDB and KMS" + }, + "dynamoEndpoint": { + "type": "string", + "description": "DynamoDB endpoint override (for local development)" + } + } + } +} diff --git a/extensions/hyperion/package.json b/extensions/hyperion/package.json new file mode 100644 index 0000000000000..f2f934b476707 --- /dev/null +++ b/extensions/hyperion/package.json @@ -0,0 +1,19 @@ +{ + "name": "@openclaw/hyperion", + "version": "2026.3.10", + "description": "Hyperion multi-tenant runtime for Nova Personal Assistant Platform", + "type": "module", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-kms": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/hyperion/src/env.ts b/extensions/hyperion/src/env.ts new file mode 100644 index 0000000000000..68feb5ad05742 --- /dev/null +++ b/extensions/hyperion/src/env.ts @@ -0,0 +1,41 @@ +import type { HyperionDynamoDBConfig } from "../../../src/hyperion/types.js"; + +/** + * Resolve the Hyperion stage from environment or plugin config. + * + * Priority: + * 1. Plugin config `stage` field + * 2. `HYPERION_STAGE` env var + * 3. Derived from `STACK_NAME` env var (e.g. "Hyperion-beta" → "beta") + */ +export function resolveStage(pluginStage?: string): string | null { + if (pluginStage) return pluginStage; + if (process.env.HYPERION_STAGE) return process.env.HYPERION_STAGE; + const stackName = process.env.STACK_NAME; + if (stackName?.startsWith("Hyperion-")) { + return stackName.replace("Hyperion-", ""); + } + return null; +} + +/** + * Build DynamoDB config from stage name. + * Table names and KMS key alias follow the CDK naming convention. + */ +export function buildDynamoConfig(params: { + stage: string; + region: string; + endpoint?: string; +}): HyperionDynamoDBConfig { + const { stage, region, endpoint } = params; + return { + region, + tenantConfigTableName: `Hyperion-${stage}-tenant-config`, + channelConfigTableName: `Hyperion-${stage}-channel-config`, + pairingCodesTableName: `Hyperion-${stage}-pairing-codes`, + userCredentialsTableName: `Hyperion-${stage}-user-credentials`, + credentialsKmsKeyId: `alias/hyperion-${stage}-credentials`, + channelConfigUserIdIndexName: "user_id-index", + ...(endpoint ? { endpoint } : {}), + }; +} diff --git a/extensions/hyperion/src/globals.ts b/extensions/hyperion/src/globals.ts new file mode 100644 index 0000000000000..0d49818feaa04 --- /dev/null +++ b/extensions/hyperion/src/globals.ts @@ -0,0 +1,38 @@ +import type { HyperionRuntime } from "../../../src/hyperion/index.js"; + +let hyperionRuntime: HyperionRuntime | null = null; + +/** + * Set the global Hyperion runtime instance. + * Called by the Hyperion plugin service on startup. + */ +export function setHyperionRuntime(runtime: HyperionRuntime): void { + hyperionRuntime = runtime; +} + +/** + * Get the global Hyperion runtime instance. + * Throws if the Hyperion plugin has not started yet. + */ +export function getHyperionRuntime(): HyperionRuntime { + if (!hyperionRuntime) { + throw new Error( + "Hyperion runtime not initialized. Ensure the hyperion plugin is enabled and started.", + ); + } + return hyperionRuntime; +} + +/** + * Check if the Hyperion runtime is available (non-throwing). + */ +export function hasHyperionRuntime(): boolean { + return hyperionRuntime !== null; +} + +/** + * Clear the global Hyperion runtime (for shutdown/testing). + */ +export function clearHyperionRuntime(): void { + hyperionRuntime = null; +} diff --git a/extensions/hyperion/src/index.ts b/extensions/hyperion/src/index.ts new file mode 100644 index 0000000000000..c771ca330e4f5 --- /dev/null +++ b/extensions/hyperion/src/index.ts @@ -0,0 +1,8 @@ +export { + getHyperionRuntime, + hasHyperionRuntime, + setHyperionRuntime, + clearHyperionRuntime, +} from "./globals.js"; +export { createHyperionPluginService, type HyperionPluginConfig } from "./service.js"; +export { resolveStage, buildDynamoConfig } from "./env.js"; diff --git a/extensions/hyperion/src/service.ts b/extensions/hyperion/src/service.ts new file mode 100644 index 0000000000000..ade87eab3d4d2 --- /dev/null +++ b/extensions/hyperion/src/service.ts @@ -0,0 +1,81 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { KMSClient } from "@aws-sdk/client-kms"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; +import { createHyperionRuntime } from "../../../src/hyperion/index.js"; +import { buildDynamoConfig, resolveStage } from "./env.js"; +import { clearHyperionRuntime, setHyperionRuntime } from "./globals.js"; + +export type HyperionPluginConfig = { + stage?: string; + region?: string; + dynamoEndpoint?: string; +}; + +/** + * OC plugin service that creates the Hyperion multi-tenant runtime on startup. + * + * On start: + * 1. Resolves stage from plugin config / env vars + * 2. Creates AWS SDK clients (DynamoDB, KMS) + * 3. Calls createHyperionRuntime() to wire all services + * 4. Stores the runtime globally via setHyperionRuntime() + * + * On stop: + * Clears the global runtime reference. + * + * Other plugins and channel handlers access it via: + * import { getHyperionRuntime } from "extensions/hyperion/src/globals.js"; + */ +export function createHyperionPluginService( + pluginConfig: HyperionPluginConfig, +): OpenClawPluginService { + return { + id: "hyperion-runtime", + + async start(ctx: OpenClawPluginServiceContext): Promise { + const stage = resolveStage(pluginConfig.stage); + if (!stage) { + ctx.logger.warn( + "Hyperion plugin: cannot determine stage. " + + "Set plugins.hyperion.stage, HYPERION_STAGE, or STACK_NAME env var.", + ); + return; + } + + const region = pluginConfig.region ?? process.env.AWS_REGION ?? "us-west-2"; + + const dynamoConfig = buildDynamoConfig({ + stage, + region, + endpoint: pluginConfig.dynamoEndpoint, + }); + + const ddbClient = new DynamoDBClient({ + region, + ...(pluginConfig.dynamoEndpoint ? { endpoint: pluginConfig.dynamoEndpoint } : {}), + }); + const docClient = DynamoDBDocumentClient.from(ddbClient, { + marshallOptions: { removeUndefinedValues: true }, + }); + const kmsClient = new KMSClient({ region }); + + const runtime = createHyperionRuntime({ + dynamoConfig, + docClient, + kmsClient, + }); + + setHyperionRuntime(runtime); + + ctx.logger.info( + `Hyperion runtime initialized (stage: ${stage}, region: ${region}, ` + + `tables: ${dynamoConfig.tenantConfigTableName}, ...)`, + ); + }, + + async stop(_ctx: OpenClawPluginServiceContext): Promise { + clearHyperionRuntime(); + }, + }; +} diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 7fcdeec744526..13f969f6c7a29 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -6,12 +6,13 @@ import { type RuntimeEnv, } from "openclaw/plugin-sdk"; import WebSocket from "ws"; -import type { NovaConfig } from "./types.js"; +import { getHyperionRuntime, hasHyperionRuntime } from "../../hyperion/src/globals.js"; import { setActiveNovaConnection } from "./connection.js"; import { resolveNovaCredentials } from "./credentials.js"; import { parseNovaInboundMessage } from "./inbound.js"; import { getNovaRuntime } from "./runtime.js"; import { sendNovaMessage } from "./send.js"; +import type { NovaConfig } from "./types.js"; export type MonitorNovaOpts = { cfg: OpenClawConfig; @@ -158,7 +159,7 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise async function handleInboundMessage( raw: string, - msgCfg: OpenClawConfig, + gatewayCfg: OpenClawConfig, msgRuntime: RuntimeEnv, ): Promise { const msg = parseNovaInboundMessage(raw); @@ -167,21 +168,37 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise return; } - const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; - const allowFrom = (novaCfg?.allowFrom ?? []).map((entry) => - String(entry).trim().toLowerCase(), - ); - - // Enforce allowlist policy — an empty allowlist blocks everyone - if (dmPolicy === "allowlist" && !allowFrom.includes("*")) { - if (allowFrom.length === 0) { - logger.info(`nova: message from ${msg.userId} dropped (allowlist is empty)`); + // In multi-tenant mode (Hyperion runtime available), load per-tenant config. + // The tenant user_id is the msg.userId from the authenticated WebSocket. + // HWS has already authenticated the user, so no allowlist check needed. + let msgCfg: OpenClawConfig; + if (hasHyperionRuntime()) { + try { + const hyperion = getHyperionRuntime(); + msgCfg = await hyperion.configLoader.loadTenantConfig(msg.userId); + } catch (err) { + logger.error(`nova: failed to load tenant config for ${msg.userId}: ${String(err)}`); return; } - const senderId = msg.userId.trim().toLowerCase(); - if (!allowFrom.includes(senderId)) { - logger.info(`nova: message from ${msg.userId} dropped (not in allowlist)`); - return; + } else { + // Single-tenant fallback: use the static gateway config with allowlist check. + msgCfg = gatewayCfg; + + const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; + const allowFrom = (novaCfg?.allowFrom ?? []).map((entry) => + String(entry).trim().toLowerCase(), + ); + + if (dmPolicy === "allowlist" && !allowFrom.includes("*")) { + if (allowFrom.length === 0) { + logger.info(`nova: message from ${msg.userId} dropped (allowlist is empty)`); + return; + } + const senderId = msg.userId.trim().toLowerCase(); + if (!allowFrom.includes(senderId)) { + logger.info(`nova: message from ${msg.userId} dropped (not in allowlist)`); + return; + } } } @@ -195,7 +212,7 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise peer: { kind: "user", id: msg.userId }, }); - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + const storePath = core.channel.session.resolveStorePath(msgCfg.session?.store, { agentId: route.agentId, }); diff --git a/src/hyperion/channel-identity-resolver.ts b/src/hyperion/channel-identity-resolver.ts new file mode 100644 index 0000000000000..b5b921580bceb --- /dev/null +++ b/src/hyperion/channel-identity-resolver.ts @@ -0,0 +1,132 @@ +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { TenantConfigLoader } from "./tenant-config-loader.js"; +import { + DEFAULT_AGENT_ID, + type CachedChannelIdentity, + type ChannelIdentityResolution, + type ChannelLink, + type HyperionPlatform, +} from "./types.js"; + +/** Identity cache TTL: 5 minutes. */ +const IDENTITY_CACHE_TTL_MS = 5 * 60_000; + +/** Maximum identity cache entries. */ +const IDENTITY_CACHE_MAX_SIZE = 50_000; + +/** + * Resolves external channel messages to internal tenant identities. + * + * This is the critical path for inbound webhook messages: + * External message arrives → resolve platform_user_id → get user_id → load config + * + * Replaces OpenClaw's in-memory allowFrom/pairing matching with DynamoDB lookups. + * The channel_config table maps (platform, platform_user_id) → user_id. + * + * Caching: in-memory with 5-minute TTL per (platform, platform_user_id). + */ +export class ChannelIdentityResolver { + private readonly dbClient: HyperionDynamoDBClient; + private readonly configLoader: TenantConfigLoader; + private readonly cache = new Map(); + + constructor(dbClient: HyperionDynamoDBClient, configLoader: TenantConfigLoader) { + this.dbClient = dbClient; + this.configLoader = configLoader; + } + + /** + * Resolve an inbound channel message to a tenant. + * + * Flow: + * 1. Look up channel_config by (platform, platform_user_id) + * 2. Extract user_id from the link + * 3. Load the full tenant config (with all channel configs assembled) + * + * @returns ChannelIdentityResolution or null if not paired. + */ + async resolve( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const channelLink = await this.getChannelLink(platform, platformUserId); + if (!channelLink) { + return null; + } + + // [claude-infra] Multi-instance: load config for the specific agent instance + // the channel is bound to. + const agentId = channelLink.agent_id || DEFAULT_AGENT_ID; + const config = await this.configLoader.loadTenantConfig(channelLink.user_id, agentId); + + return { + user_id: channelLink.user_id, + agent_id: agentId, + channelLink, + config, + }; + } + + /** + * Resolve only the user_id for a given platform identity. + * Lighter-weight than full resolve() when you don't need the config. + */ + async resolveUserId(platform: HyperionPlatform, platformUserId: string): Promise { + const channelLink = await this.getChannelLink(platform, platformUserId); + return channelLink?.user_id ?? null; + } + + /** + * Get all channel links for a specific user. + * Useful for the portal "Connected Channels" UI. + */ + async getLinksForUser(userId: string): Promise { + return this.dbClient.getChannelLinksForUser(userId); + } + + /** + * Invalidate the identity cache for a specific channel. + * Call this when a channel is paired or unpaired. + */ + invalidateCache(platform: HyperionPlatform, platformUserId: string): void { + this.cache.delete(this.cacheKey(platform, platformUserId)); + } + + /** + * Clear the entire identity cache. + */ + clearCache(): void { + this.cache.clear(); + } + + private async getChannelLink( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const key = this.cacheKey(platform, platformUserId); + const cached = this.cache.get(key); + if (cached && Date.now() - cached.cachedAt < IDENTITY_CACHE_TTL_MS) { + return cached.channelLink; + } + + const channelLink = await this.dbClient.getChannelLink(platform, platformUserId); + if (!channelLink) { + return null; + } + + // Evict oldest if full. + if (this.cache.size >= IDENTITY_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { channelLink, cachedAt: Date.now() }); + return channelLink; + } + + private cacheKey(platform: HyperionPlatform, platformUserId: string): string { + return `${platform}:${platformUserId}`; + } +} diff --git a/src/hyperion/dynamodb-client.test.ts b/src/hyperion/dynamodb-client.test.ts new file mode 100644 index 0000000000000..dfdbcf4a26af1 --- /dev/null +++ b/src/hyperion/dynamodb-client.test.ts @@ -0,0 +1,577 @@ +// @vitest-pool threads +// ↑ vi.mock for dynamic `await import()` requires threads pool (forks doesn't intercept). +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock AWS SDK commands — the source uses dynamic imports which fail without the package installed. +// Each command class just stores its input for assertion. +class MockCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } +} +vi.mock("@aws-sdk/lib-dynamodb", () => ({ + GetCommand: class extends MockCommand {}, + PutCommand: class extends MockCommand {}, + DeleteCommand: class extends MockCommand {}, + QueryCommand: class extends MockCommand {}, +})); + +import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; +import type { + HyperionDynamoDBConfig, + TenantConfig, + PairingCode, + UserCredentialsRecord, +} from "./types.js"; +import { DEFAULT_AGENT_ID } from "./types.js"; + +const TEST_CONFIG: HyperionDynamoDBConfig = { + region: "us-west-2", + tenantConfigTableName: "hyperion-test-tenant-config", + channelConfigTableName: "hyperion-test-channel-config", + pairingCodesTableName: "hyperion-test-pairing-codes", + userCredentialsTableName: "hyperion-test-user-credentials", + credentialsKmsKeyId: "arn:aws:kms:us-west-2:123456789012:key/test-key-id", + channelConfigUserIdIndexName: "user-id-index", +}; + +function createMockDocClient(): DynamoDBDocClient & { send: ReturnType } { + return { send: vi.fn() }; +} + +describe("HyperionDynamoDBClient", () => { + let mockDocClient: ReturnType; + let client: HyperionDynamoDBClient; + + beforeEach(() => { + mockDocClient = createMockDocClient(); + client = new HyperionDynamoDBClient(TEST_CONFIG, mockDocClient); + }); + + // -- getTenantConfig -- + + describe("getTenantConfig", () => { + it("returns the item when found", async () => { + const tenantConfig: TenantConfig = { + user_id: "user-1", + agent_id: "main", + display_name: "Test User", + plan: "pro", + }; + mockDocClient.send.mockResolvedValueOnce({ Item: tenantConfig }); + + const result = await client.getTenantConfig("user-1"); + + expect(result).toEqual(tenantConfig); + expect(mockDocClient.send).toHaveBeenCalledOnce(); + }); + + it("returns null when item is not found", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getTenantConfig("nonexistent-user"); + + expect(result).toBeNull(); + }); + + it("uses the correct table name and composite key", async () => { + mockDocClient.send.mockResolvedValueOnce({ Item: { user_id: "u1", agent_id: "helper" } }); + + await client.getTenantConfig("u1", "helper"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-tenant-config", + Key: { user_id: "u1", agent_id: "helper" }, + }); + }); + + it("defaults agentId to DEFAULT_AGENT_ID when not provided", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.getTenantConfig("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); + }); + }); + + // -- listTenantAgents -- + + describe("listTenantAgents", () => { + it("returns items array from query", async () => { + const agents: TenantConfig[] = [ + { user_id: "u1", agent_id: "main" }, + { user_id: "u1", agent_id: "work" }, + ]; + mockDocClient.send.mockResolvedValueOnce({ Items: agents }); + + const result = await client.listTenantAgents("u1"); + + expect(result).toEqual(agents); + expect(result).toHaveLength(2); + }); + + it("returns empty array when no items found", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.listTenantAgents("u1"); + + expect(result).toEqual([]); + }); + + it("queries the correct table with user_id", async () => { + mockDocClient.send.mockResolvedValueOnce({ Items: [] }); + + await client.listTenantAgents("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-tenant-config", + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": "u1" }, + }); + }); + }); + + // -- putTenantConfig -- + + describe("putTenantConfig", () => { + it("sets updated_at timestamp", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + const before = new Date().toISOString(); + + await client.putTenantConfig({ user_id: "u1", agent_id: "main" }); + + const command = mockDocClient.send.mock.calls[0][0]; + const item = command.input.Item; + expect(item.updated_at).toBeDefined(); + // updated_at should be a recent ISO timestamp + const updatedAt = new Date(item.updated_at).getTime(); + expect(updatedAt).toBeGreaterThanOrEqual(new Date(before).getTime()); + expect(updatedAt).toBeLessThanOrEqual(Date.now()); + }); + + it("defaults agent_id to DEFAULT_AGENT_ID when falsy", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putTenantConfig({ user_id: "u1", agent_id: "" }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Item.agent_id).toBe(DEFAULT_AGENT_ID); + }); + + it("preserves explicit agent_id", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putTenantConfig({ user_id: "u1", agent_id: "work-helper" }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Item.agent_id).toBe("work-helper"); + }); + + it("writes to the correct table", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putTenantConfig({ user_id: "u1", agent_id: "main" }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.TableName).toBe("hyperion-test-tenant-config"); + }); + }); + + // -- deleteTenantConfig -- + + describe("deleteTenantConfig", () => { + it("deletes with correct key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deleteTenantConfig("u1", "work"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-tenant-config", + Key: { user_id: "u1", agent_id: "work" }, + }); + }); + + it("defaults agentId to DEFAULT_AGENT_ID", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deleteTenantConfig("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); + }); + }); + + // -- getChannelLink -- + + describe("getChannelLink", () => { + it("returns channel link when found", async () => { + const link = { + platform: "telegram" as const, + platform_user_id: "tg-123", + user_id: "u1", + agent_id: "main", + paired_at: "2025-01-01T00:00:00Z", + channel_account_id: "bot-1", + channel_config: {}, + }; + mockDocClient.send.mockResolvedValueOnce({ Item: link }); + + const result = await client.getChannelLink("telegram", "tg-123"); + + expect(result).toEqual(link); + }); + + it("returns null when not found", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getChannelLink("slack", "unknown"); + + expect(result).toBeNull(); + }); + + it("uses correct table and composite key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.getChannelLink("discord", "disc-456"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-channel-config", + Key: { platform: "discord", platform_user_id: "disc-456" }, + }); + }); + }); + + // -- getChannelLinksForUser -- + + describe("getChannelLinksForUser", () => { + it("queries GSI with correct index name", async () => { + mockDocClient.send.mockResolvedValueOnce({ Items: [] }); + + await client.getChannelLinksForUser("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-channel-config", + IndexName: "user-id-index", + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": "u1" }, + }); + }); + + it("returns empty array when no links found", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getChannelLinksForUser("u1"); + + expect(result).toEqual([]); + }); + }); + + // -- putChannelLink -- + + describe("putChannelLink", () => { + it("writes channel link to correct table", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + const link = { + platform: "telegram" as const, + platform_user_id: "tg-123", + user_id: "u1", + agent_id: "main", + paired_at: "2025-01-01T00:00:00Z", + channel_account_id: "bot-1", + channel_config: {}, + }; + + await client.putChannelLink(link); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.TableName).toBe("hyperion-test-channel-config"); + expect(command.input.Item).toEqual(link); + }); + }); + + // -- deleteChannelLink -- + + describe("deleteChannelLink", () => { + it("deletes with correct key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deleteChannelLink("whatsapp", "wa-789"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-channel-config", + Key: { platform: "whatsapp", platform_user_id: "wa-789" }, + }); + }); + }); + + // -- getPairingCode -- + + describe("getPairingCode", () => { + it("returns code when not expired", async () => { + const futureExpiry = Math.floor(Date.now() / 1000) + 300; // 5 min from now + const pairingCode: PairingCode = { + code: "ABC123", + user_id: "u1", + agent_id: "main", + platform: "telegram", + created_at: "2025-01-01T00:00:00Z", + expires_at: futureExpiry, + }; + mockDocClient.send.mockResolvedValueOnce({ Item: pairingCode }); + + const result = await client.getPairingCode("ABC123"); + + expect(result).toEqual(pairingCode); + }); + + it("returns null for expired codes", async () => { + const pastExpiry = Math.floor(Date.now() / 1000) - 60; // 1 min ago + const pairingCode: PairingCode = { + code: "EXPIRED1", + user_id: "u1", + agent_id: "main", + platform: "telegram", + created_at: "2025-01-01T00:00:00Z", + expires_at: pastExpiry, + }; + mockDocClient.send.mockResolvedValueOnce({ Item: pairingCode }); + + const result = await client.getPairingCode("EXPIRED1"); + + expect(result).toBeNull(); + }); + + it("returns null when code not found in DynamoDB", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getPairingCode("NONEXIST"); + + expect(result).toBeNull(); + }); + + it("uses correct table and key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.getPairingCode("CODE1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-pairing-codes", + Key: { code: "CODE1" }, + }); + }); + }); + + // -- putPairingCode -- + + describe("putPairingCode", () => { + it("writes with ConditionExpression to prevent overwrites", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + const pairingCode: PairingCode = { + code: "NEW123", + user_id: "u1", + agent_id: "main", + platform: "slack", + created_at: "2025-01-01T00:00:00Z", + expires_at: Math.floor(Date.now() / 1000) + 300, + }; + + await client.putPairingCode(pairingCode); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.TableName).toBe("hyperion-test-pairing-codes"); + expect(command.input.Item).toEqual(pairingCode); + expect(command.input.ConditionExpression).toBe("attribute_not_exists(code)"); + }); + }); + + // -- deletePairingCode -- + + describe("deletePairingCode", () => { + it("deletes with correct key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deletePairingCode("CODE1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-pairing-codes", + Key: { code: "CODE1" }, + }); + }); + }); + + // -- getUserCredentials -- + + describe("getUserCredentials", () => { + const credRecord: UserCredentialsRecord = { + user_id: "u1", + agent_id: "work", + credentials_blob: "encrypted-blob", + kms_key_id: "key-1", + updated_at: "2025-01-01T00:00:00Z", + }; + + it("returns agent-specific credentials when found", async () => { + mockDocClient.send.mockResolvedValueOnce({ Item: credRecord }); + + const result = await client.getUserCredentials("u1", "work"); + + expect(result).toEqual(credRecord); + // Should only call send once (no fallback needed) + expect(mockDocClient.send).toHaveBeenCalledOnce(); + }); + + it("falls back to __shared__ when agent-specific not found", async () => { + const sharedRecord: UserCredentialsRecord = { + user_id: "u1", + agent_id: "__shared__", + credentials_blob: "shared-blob", + kms_key_id: "key-1", + updated_at: "2025-01-01T00:00:00Z", + }; + // First call: agent-specific not found + mockDocClient.send.mockResolvedValueOnce({}); + // Second call: __shared__ found + mockDocClient.send.mockResolvedValueOnce({ Item: sharedRecord }); + + const result = await client.getUserCredentials("u1", "work"); + + expect(result).toEqual(sharedRecord); + expect(mockDocClient.send).toHaveBeenCalledTimes(2); + + // Verify first call was for agent-specific + const firstCommand = mockDocClient.send.mock.calls[0][0]; + expect(firstCommand.input.Key).toEqual({ user_id: "u1", agent_id: "work" }); + + // Verify second call was for __shared__ + const secondCommand = mockDocClient.send.mock.calls[1][0]; + expect(secondCommand.input.Key).toEqual({ user_id: "u1", agent_id: "__shared__" }); + }); + + it("returns null when neither agent-specific nor __shared__ found", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getUserCredentials("u1", "work"); + + expect(result).toBeNull(); + expect(mockDocClient.send).toHaveBeenCalledTimes(2); + }); + + it("does NOT fall back when agentId is already __shared__", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + const result = await client.getUserCredentials("u1", "__shared__"); + + expect(result).toBeNull(); + // Should only call send once — no fallback to __shared__ when already querying __shared__ + expect(mockDocClient.send).toHaveBeenCalledOnce(); + }); + + it("defaults agentId to DEFAULT_AGENT_ID", async () => { + mockDocClient.send.mockResolvedValueOnce({ + Item: { ...credRecord, agent_id: DEFAULT_AGENT_ID }, + }); + + await client.getUserCredentials("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); + }); + + it("uses the correct table for all calls", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + mockDocClient.send.mockResolvedValueOnce({}); + + await client.getUserCredentials("u1", "custom-agent"); + + const firstCommand = mockDocClient.send.mock.calls[0][0]; + const secondCommand = mockDocClient.send.mock.calls[1][0]; + expect(firstCommand.input.TableName).toBe("hyperion-test-user-credentials"); + expect(secondCommand.input.TableName).toBe("hyperion-test-user-credentials"); + }); + }); + + // -- putUserCredentials -- + + describe("putUserCredentials", () => { + it("defaults agent_id when falsy", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putUserCredentials({ + user_id: "u1", + agent_id: "", + credentials_blob: "blob", + kms_key_id: "key-1", + updated_at: "2025-01-01T00:00:00Z", + }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Item.agent_id).toBe(DEFAULT_AGENT_ID); + }); + + it("preserves explicit agent_id", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putUserCredentials({ + user_id: "u1", + agent_id: "custom", + credentials_blob: "blob", + kms_key_id: "key-1", + updated_at: "2025-01-01T00:00:00Z", + }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Item.agent_id).toBe("custom"); + }); + + it("writes to the correct table", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.putUserCredentials({ + user_id: "u1", + agent_id: "main", + credentials_blob: "blob", + kms_key_id: "key-1", + updated_at: "2025-01-01T00:00:00Z", + }); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.TableName).toBe("hyperion-test-user-credentials"); + }); + }); + + // -- deleteUserCredentials -- + + describe("deleteUserCredentials", () => { + it("deletes with correct key", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deleteUserCredentials("u1", "work"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input).toEqual({ + TableName: "hyperion-test-user-credentials", + Key: { user_id: "u1", agent_id: "work" }, + }); + }); + + it("defaults agentId to DEFAULT_AGENT_ID", async () => { + mockDocClient.send.mockResolvedValueOnce({}); + + await client.deleteUserCredentials("u1"); + + const command = mockDocClient.send.mock.calls[0][0]; + expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); + }); + }); +}); diff --git a/src/hyperion/dynamodb-client.ts b/src/hyperion/dynamodb-client.ts new file mode 100644 index 0000000000000..1786063f1f035 --- /dev/null +++ b/src/hyperion/dynamodb-client.ts @@ -0,0 +1,232 @@ +import { + DEFAULT_AGENT_ID, + type ChannelLink, + type HyperionDynamoDBConfig, + type HyperionPlatform, + type PairingCode, + type TenantConfig, + type UserCredentialsRecord, +} from "./types.js"; + +/** + * Minimal DynamoDB document client interface. + * Accepts any AWS SDK v3 DynamoDBDocumentClient-compatible implementation. + */ +export type DynamoDBDocClient = { + send(command: unknown): Promise; +}; + +/** + * Wraps DynamoDB operations for Hyperion's three tables. + * Designed to work with AWS SDK v3 DynamoDBDocumentClient. + */ +export class HyperionDynamoDBClient { + private readonly config: HyperionDynamoDBConfig; + private readonly docClient: DynamoDBDocClient; + + constructor(config: HyperionDynamoDBConfig, docClient: DynamoDBDocClient) { + this.config = config; + this.docClient = docClient; + } + + // -- Tenant Config -- [claude-infra] composite key: user_id + agent_id + + async getTenantConfig( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.tenantConfigTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + const item = (result as { Item?: TenantConfig }).Item; + return item ?? null; + } + + async listTenantAgents(userId: string): Promise { + const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new QueryCommand({ + TableName: this.config.tenantConfigTableName, + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }), + ); + return (result as { Items?: TenantConfig[] }).Items ?? []; + } + + async putTenantConfig(tenantConfig: TenantConfig): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.tenantConfigTableName, + Item: { + ...tenantConfig, + agent_id: tenantConfig.agent_id || DEFAULT_AGENT_ID, + updated_at: new Date().toISOString(), + }, + }), + ); + } + + async deleteTenantConfig(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.tenantConfigTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + } + + // -- Channel Config -- + + async getChannelLink( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.channelConfigTableName, + Key: { platform, platform_user_id: platformUserId }, + }), + ); + const item = (result as { Item?: ChannelLink }).Item; + return item ?? null; + } + + async getChannelLinksForUser(userId: string): Promise { + const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new QueryCommand({ + TableName: this.config.channelConfigTableName, + IndexName: this.config.channelConfigUserIdIndexName, + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }), + ); + const items = (result as { Items?: ChannelLink[] }).Items; + return items ?? []; + } + + async putChannelLink(channelLink: ChannelLink): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.channelConfigTableName, + Item: channelLink, + }), + ); + } + + async deleteChannelLink(platform: HyperionPlatform, platformUserId: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.channelConfigTableName, + Key: { platform, platform_user_id: platformUserId }, + }), + ); + } + + // -- Pairing Codes -- + + async getPairingCode(code: string): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + }), + ); + const item = (result as { Item?: PairingCode }).Item; + if (!item) { + return null; + } + // DynamoDB TTL is eventually consistent — check expiry explicitly. + if (item.expires_at <= Math.floor(Date.now() / 1000)) { + return null; + } + return item; + } + + async putPairingCode(pairingCode: PairingCode): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.pairingCodesTableName, + Item: pairingCode, + ConditionExpression: "attribute_not_exists(code)", + }), + ); + } + + async deletePairingCode(code: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + }), + ); + } + + // -- User Credentials -- [claude-infra] composite key: user_id + agent_id + + async getUserCredentials( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + // Try agent-specific credentials first, then shared. + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + const item = (result as { Item?: UserCredentialsRecord }).Item; + if (item) { + return item; + } + + // Fall back to shared credentials if agent-specific not found. + if (agentId !== "__shared__") { + const sharedResult = await this.docClient.send( + new GetCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: "__shared__" }, + }), + ); + return (sharedResult as { Item?: UserCredentialsRecord }).Item ?? null; + } + return null; + } + + async putUserCredentials(record: UserCredentialsRecord): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.userCredentialsTableName, + Item: { + ...record, + agent_id: record.agent_id || DEFAULT_AGENT_ID, + }, + }), + ); + } + + async deleteUserCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + } +} diff --git a/src/hyperion/index.ts b/src/hyperion/index.ts new file mode 100644 index 0000000000000..5a6cc74307df8 --- /dev/null +++ b/src/hyperion/index.ts @@ -0,0 +1,68 @@ +/** + * Hyperion Integration Layer for OpenClaw + * + * This module replaces OpenClaw's single-tenant filesystem-based configuration + * with a multi-tenant DynamoDB-backed implementation for the Nova Personal + * Assistant Platform (assistant.nova.amazon.com). + * + * Architecture: + * + * OpenClaw (single-tenant) Hyperion (multi-tenant) + * ───────────────────────── ─────────────────────────── + * openclaw.json5 on disk → tenant_config DynamoDB table + * {channel}-pairing.json → pairing_codes DynamoDB table (TTL) + * {channel}-allowFrom.json → channel_config DynamoDB table + * session keys: "main" → session keys: "tenant_{userId}:{agentId}:main" + * in-memory config cache → in-memory LRU with 1-min TTL + * file lock concurrency → DynamoDB conditional writes + * + * Entry points: + * - TenantConfigLoader: loadConfig() replacement (per-tenant from DynamoDB) + * - ChannelIdentityResolver: webhook identity resolution (platform_user_id → user_id) + * - HyperionPairingStore: pairing-store.ts replacement (DynamoDB-backed) + * - Session helpers: tenant-scoped session key management + * - HyperionDynamoDBClient: DynamoDB operations for all three tables + * - createHyperionRuntime: one-call setup of the full integration layer + */ + +// Types +export { DEFAULT_AGENT_ID } from "./types.js"; +export type { + ChannelIdentityResolution, + ChannelLink, + ChannelRuntimeConfig, + CachedChannelIdentity, + CachedTenantConfig, + HyperionDynamoDBConfig, + HyperionPlatform, + PairingCode, + TenantConfig, +} from "./types.js"; + +// DynamoDB client +export { HyperionDynamoDBClient } from "./dynamodb-client.js"; +export type { DynamoDBDocClient } from "./dynamodb-client.js"; + +// Config loader (replaces io.ts loadConfig) +export { TenantConfigLoader, TenantNotFoundError } from "./tenant-config-loader.js"; + +// Identity resolution (replaces channel-config.ts resolution + pairing allowFrom) +export { ChannelIdentityResolver } from "./channel-identity-resolver.js"; + +// Pairing store (replaces pairing-store.ts file-based store) +export { HyperionPairingStore } from "./pairing-store.js"; + +// Session management (replaces session.ts with tenant-scoped keys) +export { + buildPortalSessionKey, + buildChannelSessionKey, + buildTenantMemoryNamespace, + extractAgentId, + extractInnerSessionKey, + extractTenantId, + isSessionForAgent, + isSessionForTenant, +} from "./session-manager.js"; + +// Runtime factory +export { createHyperionRuntime, type HyperionRuntime } from "./runtime.js"; diff --git a/src/hyperion/pairing-store.test.ts b/src/hyperion/pairing-store.test.ts new file mode 100644 index 0000000000000..a6c53fbbe2ca9 --- /dev/null +++ b/src/hyperion/pairing-store.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { HyperionPairingStore } from "./pairing-store.js"; +import { DEFAULT_AGENT_ID } from "./types.js"; + +function createMockDbClient() { + return { + putPairingCode: vi.fn(), + getPairingCode: vi.fn(), + deletePairingCode: vi.fn(), + putChannelLink: vi.fn(), + deleteChannelLink: vi.fn(), + } as unknown as HyperionDynamoDBClient & { + putPairingCode: ReturnType; + getPairingCode: ReturnType; + deletePairingCode: ReturnType; + putChannelLink: ReturnType; + deleteChannelLink: ReturnType; + }; +} + +describe("HyperionPairingStore", () => { + let dbClient: ReturnType; + let store: HyperionPairingStore; + + beforeEach(() => { + dbClient = createMockDbClient(); + store = new HyperionPairingStore(dbClient); + }); + + describe("generatePairingCode", () => { + it("returns a code on success", async () => { + dbClient.putPairingCode.mockResolvedValueOnce(undefined); + + const code = await store.generatePairingCode("user-1", "telegram"); + + expect(code).toBeTruthy(); + expect(typeof code).toBe("string"); + expect(code!.length).toBe(8); + expect(dbClient.putPairingCode).toHaveBeenCalledOnce(); + const savedCode = dbClient.putPairingCode.mock.calls[0][0]; + expect(savedCode.user_id).toBe("user-1"); + expect(savedCode.platform).toBe("telegram"); + expect(savedCode.agent_id).toBe(DEFAULT_AGENT_ID); + }); + + it("retries on ConditionalCheckFailedException", async () => { + const conditionalError = new Error("Conditional check failed"); + conditionalError.name = "ConditionalCheckFailedException"; + + dbClient.putPairingCode + .mockRejectedValueOnce(conditionalError) + .mockRejectedValueOnce(conditionalError) + .mockResolvedValueOnce(undefined); + + const code = await store.generatePairingCode("user-1", "telegram"); + + expect(code).toBeTruthy(); + expect(dbClient.putPairingCode).toHaveBeenCalledTimes(3); + }); + + it("returns null after 5 failed attempts", async () => { + const conditionalError = new Error("Conditional check failed"); + conditionalError.name = "ConditionalCheckFailedException"; + + dbClient.putPairingCode.mockRejectedValue(conditionalError); + + const code = await store.generatePairingCode("user-1", "telegram"); + + expect(code).toBeNull(); + expect(dbClient.putPairingCode).toHaveBeenCalledTimes(5); + }); + + it("passes agentId through to PairingCode", async () => { + dbClient.putPairingCode.mockResolvedValueOnce(undefined); + + const code = await store.generatePairingCode("user-1", "slack", "work-agent"); + + expect(code).toBeTruthy(); + const savedCode = dbClient.putPairingCode.mock.calls[0][0]; + expect(savedCode.agent_id).toBe("work-agent"); + }); + + it("throws on non-conditional errors", async () => { + const genericError = new Error("DynamoDB is down"); + genericError.name = "InternalServerError"; + + dbClient.putPairingCode.mockRejectedValueOnce(genericError); + + await expect(store.generatePairingCode("user-1", "telegram")).rejects.toThrow( + "DynamoDB is down", + ); + expect(dbClient.putPairingCode).toHaveBeenCalledOnce(); + }); + }); + + describe("redeemPairingCode", () => { + const basePairingCode = { + code: "ABCD1234", + user_id: "user-1", + agent_id: "work-agent", + platform: "telegram" as const, + created_at: "2026-01-01T00:00:00.000Z", + expires_at: Math.floor(Date.now() / 1000) + 300, + }; + + it("creates ChannelLink with correct data, inherits agent_id from pairing code", async () => { + dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.putChannelLink.mockResolvedValueOnce(undefined); + dbClient.deletePairingCode.mockResolvedValueOnce(undefined); + + const link = await store.redeemPairingCode({ + code: "ABCD1234", + platform: "telegram", + platformUserId: "tg-user-99", + channelAccountId: "bot-account", + channelConfig: { some: "config" }, + }); + + expect(link).not.toBeNull(); + expect(link!.platform).toBe("telegram"); + expect(link!.platform_user_id).toBe("tg-user-99"); + expect(link!.user_id).toBe("user-1"); + expect(link!.agent_id).toBe("work-agent"); + expect(link!.channel_account_id).toBe("bot-account"); + expect(link!.channel_config).toEqual({ some: "config" }); + expect(link!.paired_at).toBeTruthy(); + expect(dbClient.putChannelLink).toHaveBeenCalledOnce(); + }); + + it("normalizes code to uppercase", async () => { + dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.putChannelLink.mockResolvedValueOnce(undefined); + dbClient.deletePairingCode.mockResolvedValueOnce(undefined); + + await store.redeemPairingCode({ + code: " abcd1234 ", + platform: "telegram", + platformUserId: "tg-user-99", + }); + + expect(dbClient.getPairingCode).toHaveBeenCalledWith("ABCD1234"); + }); + + it("returns null for empty code", async () => { + const link = await store.redeemPairingCode({ + code: " ", + platform: "telegram", + platformUserId: "tg-user-99", + }); + + expect(link).toBeNull(); + expect(dbClient.getPairingCode).not.toHaveBeenCalled(); + }); + + it("returns null if pairing code not found", async () => { + dbClient.getPairingCode.mockResolvedValueOnce(null); + + const link = await store.redeemPairingCode({ + code: "NONEXIST", + platform: "telegram", + platformUserId: "tg-user-99", + }); + + expect(link).toBeNull(); + expect(dbClient.putChannelLink).not.toHaveBeenCalled(); + }); + + it("returns null if platform doesn't match", async () => { + dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + + const link = await store.redeemPairingCode({ + code: "ABCD1234", + platform: "slack", + platformUserId: "slack-user-1", + }); + + expect(link).toBeNull(); + expect(dbClient.putChannelLink).not.toHaveBeenCalled(); + }); + + it("deletes consumed code (best effort)", async () => { + dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.putChannelLink.mockResolvedValueOnce(undefined); + dbClient.deletePairingCode.mockRejectedValueOnce(new Error("Delete failed")); + + const link = await store.redeemPairingCode({ + code: "ABCD1234", + platform: "telegram", + platformUserId: "tg-user-99", + }); + + // Link should still be returned even though delete failed + expect(link).not.toBeNull(); + expect(dbClient.deletePairingCode).toHaveBeenCalledWith("ABCD1234"); + }); + }); + + describe("validatePairingCode", () => { + it("returns pairing code when valid", async () => { + const pairingCode = { + code: "ABCD1234", + user_id: "user-1", + agent_id: DEFAULT_AGENT_ID, + platform: "telegram" as const, + created_at: "2026-01-01T00:00:00.000Z", + expires_at: Math.floor(Date.now() / 1000) + 300, + }; + dbClient.getPairingCode.mockResolvedValueOnce(pairingCode); + + const result = await store.validatePairingCode("abcd1234", "telegram"); + + expect(result).toEqual(pairingCode); + expect(dbClient.getPairingCode).toHaveBeenCalledWith("ABCD1234"); + }); + + it("returns null on platform mismatch", async () => { + const pairingCode = { + code: "ABCD1234", + user_id: "user-1", + agent_id: DEFAULT_AGENT_ID, + platform: "telegram" as const, + created_at: "2026-01-01T00:00:00.000Z", + expires_at: Math.floor(Date.now() / 1000) + 300, + }; + dbClient.getPairingCode.mockResolvedValueOnce(pairingCode); + + const result = await store.validatePairingCode("ABCD1234", "discord"); + + expect(result).toBeNull(); + }); + }); + + describe("disconnectChannel", () => { + it("calls deleteChannelLink", async () => { + dbClient.deleteChannelLink.mockResolvedValueOnce(undefined); + + await store.disconnectChannel("telegram", "tg-user-99"); + + expect(dbClient.deleteChannelLink).toHaveBeenCalledWith("telegram", "tg-user-99"); + }); + }); +}); diff --git a/src/hyperion/pairing-store.ts b/src/hyperion/pairing-store.ts new file mode 100644 index 0000000000000..f8bc4b6ba8a65 --- /dev/null +++ b/src/hyperion/pairing-store.ts @@ -0,0 +1,164 @@ +import crypto from "node:crypto"; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { + DEFAULT_AGENT_ID, + type ChannelLink, + type ChannelRuntimeConfig, + type HyperionPlatform, + type PairingCode, +} from "./types.js"; + +const PAIRING_CODE_LENGTH = 8; +const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const PAIRING_CODE_TTL_SECONDS = 5 * 60; // 5 minutes + +/** + * DynamoDB-backed pairing store for Hyperion. + * + * Replaces OpenClaw's file-based pairing store (src/pairing/pairing-store.ts) with + * a serverless implementation using the pairing_codes DynamoDB table. + * + * Flow: + * 1. User clicks "Connect Telegram" in portal → generatePairingCode() + * 2. User sends `/connect ` to bot on Telegram → redeemPairingCode() + * 3. Code is validated, channel link is created, code is deleted + * + * Key differences from OpenClaw's file-based store: + * - No file locks needed — DynamoDB provides atomic conditional writes + * - No pruning needed — DynamoDB TTL auto-deletes expired codes + * - No max-pending limit needed — TTL-based expiry prevents unbounded growth + * - Pairing is user-initiated (portal → external), not external-initiated + */ +export class HyperionPairingStore { + private readonly dbClient: HyperionDynamoDBClient; + + constructor(dbClient: HyperionDynamoDBClient) { + this.dbClient = dbClient; + } + + /** + * Generate a pairing code for a user to connect a specific channel. + * Called from the portal when user clicks "Connect ". + * + * @returns The generated code, or null if code generation failed after retries. + */ + // [claude-infra] Multi-instance: agentId specifies which agent the channel binds to. + async generatePairingCode( + userId: string, + platform: HyperionPlatform, + agentId: string = DEFAULT_AGENT_ID, + meta?: Record, + ): Promise { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = randomCode(); + const pairingCode: PairingCode = { + code, + user_id: userId, + agent_id: agentId, + platform, + created_at: new Date().toISOString(), + expires_at: Math.floor(Date.now() / 1000) + PAIRING_CODE_TTL_SECONDS, + ...(meta ? { meta } : {}), + }; + + try { + await this.dbClient.putPairingCode(pairingCode); + return code; + } catch (err) { + // ConditionalCheckFailedException means code already exists — retry. + if ((err as { name?: string }).name === "ConditionalCheckFailedException") { + continue; + } + throw err; + } + } + return null; + } + + /** + * Redeem a pairing code and create the channel link. + * Called when a webhook arrives with `/connect `. + * + * @returns The created ChannelLink, or null if the code is invalid/expired. + */ + async redeemPairingCode(params: { + code: string; + platform: HyperionPlatform; + platformUserId: string; + channelAccountId?: string; + channelConfig?: ChannelRuntimeConfig; + }): Promise { + const normalizedCode = params.code.trim().toUpperCase(); + if (!normalizedCode) { + return null; + } + + // Fetch and validate the pairing code. + const pairingCode = await this.dbClient.getPairingCode(normalizedCode); + if (!pairingCode) { + return null; + } + + // Verify platform matches. + if (pairingCode.platform !== params.platform) { + return null; + } + + // [claude-infra] Multi-instance: channel link inherits agent_id from pairing code. + const channelLink: ChannelLink = { + platform: params.platform, + platform_user_id: params.platformUserId, + user_id: pairingCode.user_id, + agent_id: pairingCode.agent_id || DEFAULT_AGENT_ID, + paired_at: new Date().toISOString(), + channel_account_id: params.channelAccountId ?? "default", + channel_config: params.channelConfig ?? {}, + }; + + await this.dbClient.putChannelLink(channelLink); + + // Delete the consumed code (best-effort — TTL will clean up regardless). + await this.dbClient.deletePairingCode(normalizedCode).catch(() => {}); + + return channelLink; + } + + /** + * Validate a pairing code without consuming it. + * Useful for showing confirmation before completing pairing. + */ + async validatePairingCode(code: string, platform: HyperionPlatform): Promise { + const normalizedCode = code.trim().toUpperCase(); + if (!normalizedCode) { + return null; + } + + const pairingCode = await this.dbClient.getPairingCode(normalizedCode); + if (!pairingCode) { + return null; + } + if (pairingCode.platform !== platform) { + return null; + } + + return pairingCode; + } + + /** + * Disconnect a channel link. + * Called from the portal when user clicks "Disconnect ". + */ + async disconnectChannel(platform: HyperionPlatform, platformUserId: string): Promise { + await this.dbClient.deleteChannelLink(platform, platformUserId); + } +} + +function randomCode(): string { + let out = ""; + for (let i = 0; i < PAIRING_CODE_LENGTH; i++) { + const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length); + out += PAIRING_CODE_ALPHABET[idx]; + } + return out; +} diff --git a/src/hyperion/runtime.ts b/src/hyperion/runtime.ts new file mode 100644 index 0000000000000..fa71222d41cbf --- /dev/null +++ b/src/hyperion/runtime.ts @@ -0,0 +1,90 @@ +import type { OpenClawConfig } from "../config/types.js"; +import { ChannelIdentityResolver } from "./channel-identity-resolver.js"; +import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; +import { HyperionPairingStore } from "./pairing-store.js"; +import { TenantConfigLoader } from "./tenant-config-loader.js"; +import type { HyperionDynamoDBConfig } from "./types.js"; +import { UserCredentialStore, type KMSClient } from "./user-credential-store.js"; + +/** + * The complete Hyperion runtime — all services wired together. + */ +export type HyperionRuntime = { + /** DynamoDB operations for all tables. */ + dbClient: HyperionDynamoDBClient; + /** Loads per-tenant OpenClawConfig from DynamoDB (replaces loadConfig). */ + configLoader: TenantConfigLoader; + /** Resolves inbound webhooks to tenant identities (replaces allowFrom/pairing match). */ + identityResolver: ChannelIdentityResolver; + /** Manages pairing codes and channel linking (replaces file-based pairing store). */ + pairingStore: HyperionPairingStore; + /** Manages per-user encrypted credentials (API keys, bot tokens). */ + credentialStore: UserCredentialStore; +}; + +/** + * Create the full Hyperion runtime with a single call. + * + * Usage: + * ```ts + * import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; + * import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + * import { KMSClient } from "@aws-sdk/client-kms"; + * import { createHyperionRuntime } from "./hyperion/index.js"; + * + * const ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); + * const kmsClient = new KMSClient({ region: "us-east-1" }); + * const runtime = createHyperionRuntime({ + * dynamoConfig: { + * region: "us-east-1", + * tenantConfigTableName: "Hyperion-prod-tenant-config", + * channelConfigTableName: "Hyperion-prod-channel-config", + * pairingCodesTableName: "Hyperion-prod-pairing-codes", + * userCredentialsTableName: "Hyperion-prod-user-credentials", + * credentialsKmsKeyId: "alias/hyperion-prod-credentials", + * channelConfigUserIdIndexName: "user_id-index", + * }, + * docClient: ddbClient, + * kmsClient: kmsClient, + * }); + * + * // Portal SSE path (credentials auto-injected into config): + * const config = await runtime.configLoader.loadTenantConfig(userId); + * + * // Store user credentials (encrypted at rest via KMS): + * await runtime.credentialStore.putCredentials(userId, { + * model_keys: { openai: "sk-..." }, + * tool_keys: { brave_search: "BSA..." }, + * }); + * + * // Webhook path: + * const identity = await runtime.identityResolver.resolve("telegram", "12345"); + * + * // Pairing: + * const code = await runtime.pairingStore.generatePairingCode(userId, "telegram"); + * ``` + */ +export function createHyperionRuntime(params: { + dynamoConfig: HyperionDynamoDBConfig; + docClient: DynamoDBDocClient; + kmsClient: KMSClient; + defaultConfig?: Partial; +}): HyperionRuntime { + const dbClient = new HyperionDynamoDBClient(params.dynamoConfig, params.docClient); + const credentialStore = new UserCredentialStore( + dbClient, + params.kmsClient, + params.dynamoConfig.credentialsKmsKeyId, + ); + const configLoader = new TenantConfigLoader(dbClient, params.defaultConfig, credentialStore); + const identityResolver = new ChannelIdentityResolver(dbClient, configLoader); + const pairingStore = new HyperionPairingStore(dbClient); + + return { + dbClient, + configLoader, + identityResolver, + pairingStore, + credentialStore, + }; +} diff --git a/src/hyperion/session-manager.test.ts b/src/hyperion/session-manager.test.ts new file mode 100644 index 0000000000000..4673b2eca6c8b --- /dev/null +++ b/src/hyperion/session-manager.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { + buildPortalSessionKey, + buildChannelSessionKey, + extractTenantId, + extractAgentId, + extractInnerSessionKey, + isSessionForTenant, + isSessionForAgent, + buildTenantMemoryNamespace, +} from "./session-manager.js"; + +describe("session-manager", () => { + describe("buildPortalSessionKey", () => { + it("uses default agentId", () => { + expect(buildPortalSessionKey("user123")).toBe("tenant_user123:main:main"); + }); + + it("uses custom agentId", () => { + expect(buildPortalSessionKey("user123", "work")).toBe("tenant_user123:work:main"); + }); + }); + + describe("buildChannelSessionKey", () => { + it("builds key without threadId", () => { + expect(buildChannelSessionKey("user123", "main", "telegram", "98765")).toBe( + "tenant_user123:main:telegram:98765", + ); + }); + + it("builds key with threadId", () => { + expect(buildChannelSessionKey("user123", "main", "slack", "U111", "T999")).toBe( + "tenant_user123:main:slack:U111:T999", + ); + }); + + it("defaults agentId to main", () => { + expect(buildChannelSessionKey("user123", undefined, "discord", "D555")).toBe( + "tenant_user123:main:discord:D555", + ); + }); + + it("uses custom agentId", () => { + expect(buildChannelSessionKey("user123", "personal", "whatsapp", "W777")).toBe( + "tenant_user123:personal:whatsapp:W777", + ); + }); + }); + + describe("extractTenantId", () => { + it("extracts userId from portal key", () => { + expect(extractTenantId("tenant_user123:main:main")).toBe("user123"); + }); + + it("extracts userId from channel key", () => { + expect(extractTenantId("tenant_abc:work:telegram:98765")).toBe("abc"); + }); + + it("returns null for non-tenant key", () => { + expect(extractTenantId("main")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractTenantId("")).toBeNull(); + }); + + it("returns null for tenant_ prefix with no separator", () => { + expect(extractTenantId("tenant_user123")).toBeNull(); + }); + + it("handles tenant_ prefix with immediate separator", () => { + expect(extractTenantId("tenant_:main:main")).toBe(""); + }); + }); + + describe("extractAgentId", () => { + it("extracts agentId from portal key", () => { + expect(extractAgentId("tenant_user123:main:main")).toBe("main"); + }); + + it("extracts custom agentId", () => { + expect(extractAgentId("tenant_user123:work:telegram:98765")).toBe("work"); + }); + + it("returns default for non-tenant key", () => { + expect(extractAgentId("some:other:key")).toBe("main"); + }); + + it("returns default when no separator after prefix", () => { + expect(extractAgentId("tenant_user123")).toBe("main"); + }); + + it("returns agentId when only userId and agentId present", () => { + expect(extractAgentId("tenant_user123:work")).toBe("work"); + }); + + it("returns default for empty agentId segment", () => { + expect(extractAgentId("tenant_user123::rest")).toBe("main"); + }); + }); + + describe("extractInnerSessionKey", () => { + it("extracts inner key from portal session", () => { + expect(extractInnerSessionKey("tenant_user123:main:main")).toBe("main"); + }); + + it("extracts inner key from channel session", () => { + expect(extractInnerSessionKey("tenant_user123:work:telegram:98765")).toBe("telegram:98765"); + }); + + it("extracts inner key with threadId", () => { + expect(extractInnerSessionKey("tenant_user123:main:slack:U111:T999")).toBe("slack:U111:T999"); + }); + + it("returns original key for non-tenant key", () => { + expect(extractInnerSessionKey("telegram:12345")).toBe("telegram:12345"); + }); + + it("returns original when no separator after prefix", () => { + expect(extractInnerSessionKey("tenant_user123")).toBe("tenant_user123"); + }); + + it("returns agentId when no second separator", () => { + expect(extractInnerSessionKey("tenant_user123:work")).toBe("work"); + }); + }); + + describe("isSessionForTenant", () => { + it("returns true for matching userId", () => { + expect(isSessionForTenant("tenant_user123:main:main", "user123")).toBe(true); + }); + + it("returns false for different userId", () => { + expect(isSessionForTenant("tenant_user123:main:main", "user456")).toBe(false); + }); + + it("returns false for non-tenant key", () => { + expect(isSessionForTenant("main", "user123")).toBe(false); + }); + + it("does not match partial userId prefix", () => { + expect(isSessionForTenant("tenant_user123:main:main", "user12")).toBe(false); + }); + }); + + describe("isSessionForAgent", () => { + it("returns true for matching userId and default agentId", () => { + expect(isSessionForAgent("tenant_user123:main:main", "user123")).toBe(true); + }); + + it("returns true for matching userId and custom agentId", () => { + expect(isSessionForAgent("tenant_user123:work:telegram:98765", "user123", "work")).toBe(true); + }); + + it("returns false for wrong agentId", () => { + expect(isSessionForAgent("tenant_user123:work:main", "user123", "personal")).toBe(false); + }); + + it("returns false for wrong userId", () => { + expect(isSessionForAgent("tenant_user123:main:main", "user456")).toBe(false); + }); + + it("returns false for non-tenant key", () => { + expect(isSessionForAgent("main", "user123")).toBe(false); + }); + }); + + describe("buildTenantMemoryNamespace", () => { + it("builds namespace with default agentId", () => { + expect(buildTenantMemoryNamespace("user123")).toBe("tenant_user123:main"); + }); + + it("builds namespace with custom agentId", () => { + expect(buildTenantMemoryNamespace("user123", "work")).toBe("tenant_user123:work"); + }); + }); +}); diff --git a/src/hyperion/session-manager.ts b/src/hyperion/session-manager.ts new file mode 100644 index 0000000000000..4aab48d39f538 --- /dev/null +++ b/src/hyperion/session-manager.ts @@ -0,0 +1,158 @@ +import { DEFAULT_AGENT_ID, type HyperionPlatform } from "./types.js"; + +/** + * Tenant-scoped session key management for Hyperion. + * [claude-infra] Multi-instance: session keys now include agent_id. + * + * OpenClaw's sessions are identified by session keys (src/channels/session.ts, + * src/routing/session-key.ts). In single-tenant mode, session keys are simple + * channel identifiers like "telegram:12345" or "main". + * + * For multi-tenant Hyperion, all session keys are namespaced with the tenant's + * user_id and agent_id to ensure complete isolation between tenants and agents: + * + * Single-tenant OpenClaw: "main" + * Multi-tenant Hyperion: "tenant_user123:main:main" + * + * Single-tenant OpenClaw: "telegram:12345" + * Multi-tenant Hyperion: "tenant_user123:main:telegram:12345" + * + * Format: tenant_{userId}:{agentId}:{rest} + */ + +const TENANT_PREFIX = "tenant_"; +const SEPARATOR = ":"; + +/** + * Build a tenant-scoped session key for portal (synchronous) interactions. + * [claude-infra] Multi-instance: includes agentId. + * + * @param userId - Internal Hyperion user ID + * @param agentId - Agent instance ID (default: "main") + * @returns Scoped session key like "tenant_user123:main:main" + */ +export function buildPortalSessionKey(userId: string, agentId: string = DEFAULT_AGENT_ID): string { + return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}main`; +} + +/** + * Build a tenant-scoped session key for an external channel interaction. + * [claude-infra] Multi-instance: includes agentId. + * + * @param userId - Internal Hyperion user ID + * @param agentId - Agent instance ID (default: "main") + * @param platform - External platform identifier + * @param platformUserId - User's ID on the external platform + * @param threadId - Optional thread/conversation ID for threaded sessions + * @returns Scoped session key like "tenant_user123:main:telegram:98765" + */ +export function buildChannelSessionKey( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + platform: HyperionPlatform, + platformUserId: string, + threadId?: string, +): string { + const base = `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}${platform}${SEPARATOR}${platformUserId}`; + if (threadId) { + return `${base}${SEPARATOR}${threadId}`; + } + return base; +} + +/** + * Extract the tenant user_id from a scoped session key. + * + * @returns The user_id, or null if the key is not tenant-scoped. + */ +export function extractTenantId(sessionKey: string): string | null { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return null; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + const separatorIdx = afterPrefix.indexOf(SEPARATOR); + if (separatorIdx < 0) { + return null; + } + return afterPrefix.slice(0, separatorIdx); +} + +/** + * Extract the agent_id from a scoped session key. + * [claude-infra] Multi-instance support. + * + * Format: tenant_{userId}:{agentId}:{rest} + * @returns The agent_id, or DEFAULT_AGENT_ID if not found. + */ +export function extractAgentId(sessionKey: string): string { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return DEFAULT_AGENT_ID; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + const firstSep = afterPrefix.indexOf(SEPARATOR); + if (firstSep < 0) { + return DEFAULT_AGENT_ID; + } + const afterUserId = afterPrefix.slice(firstSep + 1); + const secondSep = afterUserId.indexOf(SEPARATOR); + if (secondSep < 0) { + return afterUserId || DEFAULT_AGENT_ID; + } + return afterUserId.slice(0, secondSep) || DEFAULT_AGENT_ID; +} + +/** + * Extract the inner session key (without tenant prefix and agent_id). + * This is what gets passed to OpenClaw's session internals. + * [claude-infra] Multi-instance: strips both tenant_ prefix and agentId. + * + * @returns The inner key, or the original key if not tenant-scoped. + */ +export function extractInnerSessionKey(sessionKey: string): string { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return sessionKey; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + // Skip userId + const firstSep = afterPrefix.indexOf(SEPARATOR); + if (firstSep < 0) { + return sessionKey; + } + const afterUserId = afterPrefix.slice(firstSep + 1); + // Skip agentId + const secondSep = afterUserId.indexOf(SEPARATOR); + if (secondSep < 0) { + return afterUserId; + } + return afterUserId.slice(secondSep + 1); +} + +/** + * Check if a session key belongs to a specific tenant. + */ +export function isSessionForTenant(sessionKey: string, userId: string): boolean { + return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}`); +} + +/** + * Check if a session key belongs to a specific tenant+agent. + * [claude-infra] Multi-instance support. + */ +export function isSessionForAgent( + sessionKey: string, + userId: string, + agentId: string = DEFAULT_AGENT_ID, +): boolean { + return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}`); +} + +/** + * Build the AgentCore memory namespace for a tenant+agent. + * [claude-infra] Multi-instance: each agent instance has isolated memory. + */ +export function buildTenantMemoryNamespace( + userId: string, + agentId: string = DEFAULT_AGENT_ID, +): string { + return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}`; +} diff --git a/src/hyperion/tenant-config-loader.test.ts b/src/hyperion/tenant-config-loader.test.ts new file mode 100644 index 0000000000000..9f64db8887727 --- /dev/null +++ b/src/hyperion/tenant-config-loader.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { TenantConfigLoader, TenantNotFoundError } from "./tenant-config-loader.js"; +import type { ChannelLink, TenantConfig } from "./types.js"; +import type { UserCredentialStore } from "./user-credential-store.js"; + +function createMockFns() { + return { + getTenantConfig: vi.fn(), + listTenantAgents: vi.fn(), + putTenantConfig: vi.fn(), + deleteTenantConfig: vi.fn(), + getChannelLink: vi.fn(), + getChannelLinksForUser: vi.fn(), + putChannelLink: vi.fn(), + deleteChannelLink: vi.fn(), + getPairingCode: vi.fn(), + putPairingCode: vi.fn(), + deletePairingCode: vi.fn(), + getUserCredentials: vi.fn(), + putUserCredentials: vi.fn(), + deleteUserCredentials: vi.fn(), + }; +} + +function createMockCredFns() { + return { + getCredentials: vi.fn(), + putCredentials: vi.fn(), + deleteCredentials: vi.fn(), + invalidateCache: vi.fn(), + clearCache: vi.fn(), + }; +} + +const baseTenantConfig: TenantConfig = { + user_id: "user1", + agent_id: "main", + model: "anthropic.claude-sonnet-4-20250514", + custom_instructions: "Be helpful", + tools: ["brave_search", "calculator"], + skills: ["web"], +}; + +const channelLink: ChannelLink = { + platform: "telegram", + platform_user_id: "tg98765", + user_id: "user1", + agent_id: "main", + paired_at: "2026-01-01T00:00:00.000Z", + channel_account_id: "bot1", + channel_config: { streaming: "partial" }, +}; + +describe("TenantConfigLoader", () => { + let mockDb: ReturnType; + let mockCreds: ReturnType; + let loader: TenantConfigLoader; + + beforeEach(() => { + mockDb = createMockFns(); + mockCreds = createMockCredFns(); + loader = new TenantConfigLoader( + mockDb as unknown as HyperionDynamoDBClient, + {}, + mockCreds as unknown as UserCredentialStore, + ); + }); + + // -- loadTenantConfig -- + + describe("loadTenantConfig", () => { + test("builds config from DynamoDB data", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([channelLink]); + mockCreds.getCredentials.mockResolvedValue(null); + + const config = await loader.loadTenantConfig("user1"); + + expect(config).toHaveProperty( + "agents.list.0.model.primary", + "anthropic.claude-sonnet-4-20250514", + ); + expect(config).toHaveProperty("tools.allow", ["brave_search", "calculator"]); + expect(config).toHaveProperty("channels.telegram"); + expect(config).toHaveProperty("channels.telegram.enabled", true); + }); + + test("throws TenantNotFoundError when config missing", async () => { + mockDb.getTenantConfig.mockResolvedValue(null); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + await expect(loader.loadTenantConfig("nonexistent")).rejects.toThrow(TenantNotFoundError); + }); + + test("filters channel links by agentId", async () => { + const workLink: ChannelLink = { ...channelLink, agent_id: "work" }; + const mainLink: ChannelLink = { + ...channelLink, + platform_user_id: "tg11111", + agent_id: "main", + }; + + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([workLink, mainLink]); + mockCreds.getCredentials.mockResolvedValue(null); + + const config = await loader.loadTenantConfig("user1", "main"); + + // Only mainLink should be included (filtered to agent_id=main) + expect(config).toHaveProperty("channels.telegram"); + const plain = JSON.parse(JSON.stringify(config)); + const accountKeys = Object.keys(plain.channels.telegram.accounts); + expect(accountKeys).toHaveLength(1); + // The account's allowFrom should reference mainLink's platform_user_id + expect(plain.channels.telegram.accounts[accountKeys[0]].allowFrom).toEqual(["tg11111"]); + }); + + test("injects model_keys from credentials", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue({ + model_keys: { openai: "sk-test123" }, + }); + + const config = await loader.loadTenantConfig("user1"); + expect(config).toHaveProperty("models.providers.openai.apiKey", "sk-test123"); + }); + + test("injects channel_tokens into channel accounts", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([channelLink]); + mockCreds.getCredentials.mockResolvedValue({ + channel_tokens: { telegram: "bot-token-123" }, + }); + + const config = await loader.loadTenantConfig("user1"); + expect(config).toHaveProperty("channels.telegram.accounts"); + const plain = JSON.parse(JSON.stringify(config)); + expect(Object.values(plain.channels.telegram.accounts)[0]).toHaveProperty( + "botToken", + "bot-token-123", + ); + }); + }); + + // -- caching -- + + describe("caching", () => { + test("returns cached config on second call", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + const config1 = await loader.loadTenantConfig("user1"); + const config2 = await loader.loadTenantConfig("user1"); + + expect(config1).toBe(config2); // same reference + expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(1); + }); + + test("separate cache keys for different agents", async () => { + const workConfig = { ...baseTenantConfig, agent_id: "work", model: "gpt-4" }; + mockDb.getTenantConfig + .mockResolvedValueOnce(baseTenantConfig) + .mockResolvedValueOnce(workConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + const main = await loader.loadTenantConfig("user1", "main"); + const work = await loader.loadTenantConfig("user1", "work"); + + expect(main).toHaveProperty( + "agents.list.0.model.primary", + "anthropic.claude-sonnet-4-20250514", + ); + expect(work).toHaveProperty("agents.list.0.model.primary", "gpt-4"); + expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(2); + }); + + test("invalidateCache forces refetch", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + await loader.loadTenantConfig("user1"); + loader.invalidateCache("user1"); + await loader.loadTenantConfig("user1"); + + expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(2); + }); + + test("clearCache empties all entries", async () => { + mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + await loader.loadTenantConfig("user1"); + await loader.loadTenantConfig("user2"); + loader.clearCache(); + await loader.loadTenantConfig("user1"); + + // getTenantConfig called 3 times: user1, user2, user1 (after clear) + expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(3); + }); + }); + + // -- custom_instructions / profile merging -- + + describe("agent config merging", () => { + test("applies custom_instructions to agents config", async () => { + mockDb.getTenantConfig.mockResolvedValue({ + ...baseTenantConfig, + custom_instructions: "Always respond in French", + }); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + const config = await loader.loadTenantConfig("user1"); + expect(config).toHaveProperty("agents.list.0.customInstructions", "Always respond in French"); + }); + + test("applies profile settings to agents config", async () => { + mockDb.getTenantConfig.mockResolvedValue({ + ...baseTenantConfig, + profile: { name: "TestBot", avatar: "robot" }, + }); + mockDb.getChannelLinksForUser.mockResolvedValue([]); + mockCreds.getCredentials.mockResolvedValue(null); + + const config = await loader.loadTenantConfig("user1"); + expect(config).toHaveProperty("agents.list.0.name", "TestBot"); + expect(config).toHaveProperty("agents.list.0.avatar", "robot"); + }); + }); + + // -- TenantNotFoundError -- + + describe("TenantNotFoundError", () => { + test("has correct name and tenantId", () => { + const err = new TenantNotFoundError("user999"); + expect(err.name).toBe("TenantNotFoundError"); + expect(err.tenantId).toBe("user999"); + expect(err.message).toBe("Tenant not found: user999"); + expect(err).toBeInstanceOf(Error); + }); + }); +}); diff --git a/src/hyperion/tenant-config-loader.ts b/src/hyperion/tenant-config-loader.ts new file mode 100644 index 0000000000000..9ed5648e97cde --- /dev/null +++ b/src/hyperion/tenant-config-loader.ts @@ -0,0 +1,272 @@ +import type { ChannelsConfig } from "../config/types.channels.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { + DEFAULT_AGENT_ID, + type CachedTenantConfig, + type ChannelLink, + type TenantConfig, + type UserCredentials, +} from "./types.js"; +import type { UserCredentialStore } from "./user-credential-store.js"; + +/** Config cache TTL: 1 minute. */ +const CONFIG_CACHE_TTL_MS = 60_000; + +/** Maximum cache entries to prevent unbounded growth. */ +const CONFIG_CACHE_MAX_SIZE = 10_000; + +/** + * Loads and assembles OpenClawConfig for a specific tenant from DynamoDB. + * + * Replaces OpenClaw's filesystem-based `loadConfig()` (src/config/io.ts) with + * a multi-tenant DynamoDB-backed implementation: + * + * 1. Fetches tenant_config (profile, model, tools, skills, etc.) + * 2. Fetches all channel_config links for the user via GSI + * 3. Assembles the OpenClawConfig.channels section dynamically + * 4. Merges tenant base config with channel configs into a complete OpenClawConfig + * + * Caching: in-memory LRU with 1-minute TTL per tenant. + */ +export class TenantConfigLoader { + private readonly dbClient: HyperionDynamoDBClient; + private readonly credentialStore: UserCredentialStore | null; + private readonly cache = new Map(); + private readonly defaultConfig: Partial; + + constructor( + dbClient: HyperionDynamoDBClient, + defaultConfig?: Partial, + credentialStore?: UserCredentialStore, + ) { + this.dbClient = dbClient; + this.defaultConfig = defaultConfig ?? {}; + this.credentialStore = credentialStore ?? null; + } + + /** + * Load the full OpenClawConfig for a tenant+agent, with caching. + * [claude-infra] Multi-instance: agentId defaults to "main". + */ + async loadTenantConfig( + tenantId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const cacheKey = `${tenantId}:${agentId}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < CONFIG_CACHE_TTL_MS) { + return cached.config; + } + + const config = await this.buildTenantConfig(tenantId, agentId); + + // Evict oldest entries if cache is full. + if (this.cache.size >= CONFIG_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(cacheKey, { config, cachedAt: Date.now() }); + return config; + } + + /** + * Invalidate the cached config for a tenant+agent. + * Call this when tenant config or channel links are updated. + * [claude-infra] Multi-instance: agentId defaults to "main". + */ + invalidateCache(tenantId: string, agentId: string = DEFAULT_AGENT_ID): void { + this.cache.delete(`${tenantId}:${agentId}`); + } + + /** + * Clear the entire config cache. + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Build the full OpenClawConfig for a tenant by reading DynamoDB. + */ + private async buildTenantConfig( + tenantId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + // Fetch tenant config, channel links, and credentials in parallel. + // [claude-infra] Multi-instance: config + credentials keyed by (userId, agentId), + // channel links filtered to matching agent_id. + const [tenantConfig, allChannelLinks, credentials] = await Promise.all([ + this.dbClient.getTenantConfig(tenantId, agentId), + this.dbClient.getChannelLinksForUser(tenantId), + this.credentialStore?.getCredentials(tenantId, agentId) ?? Promise.resolve(null), + ]); + // Filter channel links to only those bound to this agent instance. + const channelLinks = allChannelLinks.filter( + (link) => (link.agent_id || DEFAULT_AGENT_ID) === agentId, + ); + + if (!tenantConfig) { + throw new TenantNotFoundError(tenantId); + } + + const channels = this.assembleChannelsConfig(channelLinks); + return this.mergeConfig(tenantConfig, channels, credentials); + } + + /** + * Assemble OpenClaw's ChannelsConfig from the tenant's channel links. + * + * Each channel link produces an entry under the appropriate platform key. + * Multiple links for the same platform use the multi-account pattern: + * channels.telegram.accounts[accountId] = { ...config, allowFrom: [platformUserId] } + */ + private assembleChannelsConfig(channelLinks: ChannelLink[]): ChannelsConfig { + const channels: ChannelsConfig = {}; + + for (const link of channelLinks) { + const platform = link.platform; + const accountId = link.channel_account_id || "default"; + + if (!channels[platform]) { + channels[platform] = { + enabled: true, + accounts: {}, + defaultAccount: accountId, + }; + } + + channels[platform].accounts[accountId] = this.buildAccountConfig(link); + } + + return channels; + } + + /** + * Build a per-account channel config from a channel link. + * The platform_user_id is automatically added to allowFrom + * to authorize the linked external identity. + */ + private buildAccountConfig(link: ChannelLink): Record { + const runtimeConfig = link.channel_config ?? {}; + return { + ...runtimeConfig, + // Authorize this specific external user for DM access. + allowFrom: [link.platform_user_id], + // DM policy is "open" for paired users — they've already been verified. + dmPolicy: runtimeConfig.dmPolicy ?? "open", + }; + } + + /** + * Merge tenant-level settings with the assembled channel config + * and decrypted credentials into a complete OpenClawConfig. + */ + private mergeConfig( + tenant: TenantConfig, + channels: ChannelsConfig, + credentials: UserCredentials | null, + ): OpenClawConfig { + const config: OpenClawConfig = { + ...this.defaultConfig, + channels, + }; + + // Apply tenant-level agent configuration (model, profile, custom instructions). + // OC agents are under config.agents.list — each entry is an AgentConfig. + const baseAgent = config.agents?.list?.[0] ?? { id: "default" }; + const agentPatches: Record = {}; + + if (tenant.model) { + agentPatches.model = { primary: tenant.model }; + } + if (tenant.custom_instructions) { + agentPatches.customInstructions = tenant.custom_instructions; + } + if (tenant.profile) { + Object.assign(agentPatches, tenant.profile); + } + + if (Object.keys(agentPatches).length > 0) { + config.agents = { + ...config.agents, + list: [{ ...baseAgent, ...agentPatches }], + }; + } + + // Inject per-user model provider API keys from encrypted credential store. + if (credentials?.model_keys) { + const providers = { ...config.models?.providers }; + for (const [provider, apiKey] of Object.entries(credentials.model_keys)) { + providers[provider] = { ...providers[provider], apiKey }; + } + config.models = { ...config.models, providers }; + } + + // Apply tenant-level tool permissions. + if (tenant.tools) { + config.tools = { + ...config.tools, + allow: tenant.tools, + }; + } + + // Inject per-user tool API keys from encrypted credential store. + if (credentials?.tool_keys) { + const web = config.tools?.web ?? {}; + const search = web.search ?? {}; + for (const [toolName, apiKey] of Object.entries(credentials.tool_keys)) { + if ( + toolName === "brave_search" || + toolName === "gemini" || + toolName === "grok" || + toolName === "kimi" || + toolName === "perplexity" + ) { + config.tools = { + ...config.tools, + web: { ...web, search: { ...search, apiKey } }, + }; + } + } + } + + // Apply tenant-level skill permissions. + if (tenant.skills) { + config.skills = { + ...config.skills, + allowBundled: tenant.skills, + }; + } + + // Inject per-user channel bot tokens into assembled channel accounts. + if (credentials?.channel_tokens) { + for (const [platform, token] of Object.entries(credentials.channel_tokens)) { + const platformConfig = channels[platform]; + if (platformConfig?.accounts) { + for (const account of Object.values(platformConfig.accounts)) { + account.botToken = token; + } + } + } + } + + return config; + } +} + +/** + * Thrown when a tenant_id cannot be found in the tenant_config table. + */ +export class TenantNotFoundError extends Error { + public readonly tenantId: string; + + constructor(tenantId: string) { + super(`Tenant not found: ${tenantId}`); + this.name = "TenantNotFoundError"; + this.tenantId = tenantId; + } +} diff --git a/src/hyperion/types.ts b/src/hyperion/types.ts new file mode 100644 index 0000000000000..f5114e96ed71c --- /dev/null +++ b/src/hyperion/types.ts @@ -0,0 +1,204 @@ +import type { OpenClawConfig } from "../config/types.js"; + +/** + * Supported external channel platforms for Hyperion. + */ +export type HyperionPlatform = "telegram" | "slack" | "whatsapp" | "discord"; + +/** + * Default agent ID for single-instance users (backwards compatible). + * [claude-infra] Multi-instance support + */ +export const DEFAULT_AGENT_ID = "main"; + +/** + * A channel link record stored in the channel_config DynamoDB table. + * Maps an external platform identity to an internal Hyperion user_id. + * + * Table schema: + * PK: platform (HyperionPlatform) + * SK: platform_user_id (string) + * GSI1PK: user_id (string) + */ +export type ChannelLink = { + /** External platform identifier (e.g., "telegram", "slack"). */ + platform: HyperionPlatform; + /** The user's identity on the external platform (e.g., Telegram user ID, Slack team+user). */ + platform_user_id: string; + /** Internal Hyperion user ID this channel is linked to. */ + user_id: string; + /** Agent instance this channel is bound to. Default: "main". [claude-infra] */ + agent_id: string; + /** ISO timestamp when the channel was paired. */ + paired_at: string; + /** Account ID within the channel (e.g., bot token alias). */ + channel_account_id: string; + /** Platform-specific channel runtime configuration. */ + channel_config: ChannelRuntimeConfig; +}; + +/** + * Platform-specific runtime configuration stored per channel link. + * Subset of OpenClaw's per-account channel config, relevant for multi-tenant operation. + */ +export type ChannelRuntimeConfig = { + /** DM policy for this channel link. */ + dmPolicy?: "pairing" | "open" | "disabled"; + /** Streaming mode for message delivery. */ + streaming?: "off" | "partial" | "block" | "progress"; + /** Max text chunk size for message splitting. */ + textChunkLimit?: number; + /** Reply threading mode. */ + replyToMode?: string; + /** Group message policy. */ + groupPolicy?: Record; + /** Bot token or Secrets Manager ARN reference. */ + credentialRef?: string; + /** Additional platform-specific settings. */ + [key: string]: unknown; +}; + +/** + * Tenant configuration stored in the tenant_config DynamoDB table. + * + * Table schema: + * PK: user_id (string), SK: agent_id (string) [claude-infra] + */ +export type TenantConfig = { + /** Internal Hyperion user ID. */ + user_id: string; + /** Agent instance ID. Default: "main". [claude-infra] */ + agent_id: string; + /** User's display name. */ + display_name?: string; + /** User's preferred model configuration. */ + model?: string; + /** User's custom instructions for the agent. */ + custom_instructions?: string; + /** User's subscription plan. */ + plan?: "free" | "pro" | "enterprise"; + /** Usage limits for the tenant. */ + limits?: { + messages_per_day?: number; + messages_per_month?: number; + }; + /** Agent profile/persona settings. */ + profile?: Record; + /** Enabled tools for this tenant. */ + tools?: string[]; + /** Enabled skills for this tenant. */ + skills?: string[]; + /** ISO timestamp when config was last updated. */ + updated_at?: string; +}; + +/** + * A pairing code record stored in the pairing_codes DynamoDB table. + * + * Table schema: + * PK: code (string) + * TTL: expires_at (number, epoch seconds) + */ +export type PairingCode = { + /** The human-friendly pairing code. */ + code: string; + /** Internal user ID that initiated the pairing. */ + user_id: string; + /** Agent instance to bind the channel to. Default: "main". [claude-infra] */ + agent_id: string; + /** Target platform for the pairing. */ + platform: HyperionPlatform; + /** ISO timestamp when the code was created. */ + created_at: string; + /** TTL attribute — epoch seconds when this code expires. */ + expires_at: number; + /** Optional metadata from the pairing request. */ + meta?: Record; +}; + +/** + * Result of resolving an inbound channel message to a tenant. + */ +export type ChannelIdentityResolution = { + /** The resolved internal user ID. */ + user_id: string; + /** The resolved agent instance ID. [claude-infra] */ + agent_id: string; + /** The channel link record. */ + channelLink: ChannelLink; + /** The assembled OpenClawConfig for this tenant+agent. */ + config: OpenClawConfig; +}; + +/** + * Per-user credentials stored encrypted in the user_credentials DynamoDB table. + * Each field is optional — users only store the keys they actually use. + * + * Values are encrypted at rest via KMS envelope encryption with + * encryption context { user_id: "" } to ensure per-tenant isolation. + */ +export type UserCredentials = { + /** Model provider API keys (e.g., openai, anthropic, google). */ + model_keys?: Record; + /** Tool API keys (e.g., brave_search, firecrawl, perplexity). */ + tool_keys?: Record; + /** Channel bot tokens (e.g., telegram, discord, slack). */ + channel_tokens?: Record; + /** Arbitrary additional credentials for custom tools/plugins. */ + custom?: Record; +}; + +/** + * Raw record stored in the user_credentials DynamoDB table. + * The credentials_blob field contains KMS-encrypted JSON of UserCredentials. + */ +export type UserCredentialsRecord = { + /** Internal Hyperion user ID (PK). */ + user_id: string; + /** Agent instance ID (SK). Default: "main". [claude-infra] */ + agent_id: string; + /** KMS-encrypted credentials blob (base64-encoded ciphertext). */ + credentials_blob: string; + /** KMS key ID used for encryption (for key rotation tracking). */ + kms_key_id: string; + /** ISO timestamp when credentials were last updated. */ + updated_at: string; +}; + +/** + * Configuration for the Hyperion DynamoDB integration. + */ +export type HyperionDynamoDBConfig = { + /** AWS region for DynamoDB. */ + region: string; + /** Tenant config table name. */ + tenantConfigTableName: string; + /** Channel config table name. */ + channelConfigTableName: string; + /** Pairing codes table name. */ + pairingCodesTableName: string; + /** User credentials table name. */ + userCredentialsTableName: string; + /** KMS key ID (ARN or alias) for credential encryption. */ + credentialsKmsKeyId: string; + /** GSI name for user_id lookups on channel_config. */ + channelConfigUserIdIndexName: string; + /** Optional DynamoDB endpoint override (for local development). */ + endpoint?: string; +}; + +/** + * Cache entry for tenant configs with TTL. + */ +export type CachedTenantConfig = { + config: OpenClawConfig; + cachedAt: number; +}; + +/** + * Cache entry for channel identity resolution with TTL. + */ +export type CachedChannelIdentity = { + channelLink: ChannelLink; + cachedAt: number; +}; diff --git a/src/hyperion/user-credential-store.ts b/src/hyperion/user-credential-store.ts new file mode 100644 index 0000000000000..03a13b25ebe0f --- /dev/null +++ b/src/hyperion/user-credential-store.ts @@ -0,0 +1,186 @@ +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { DEFAULT_AGENT_ID, type UserCredentials } from "./types.js"; + +/** + * Minimal KMS client interface. + * Accepts any AWS SDK v3 KMSClient-compatible implementation. + */ +export type KMSClient = { + send(command: unknown): Promise; +}; + +/** Credential cache TTL: 2 minutes (shorter than config cache for security). */ +const CREDENTIAL_CACHE_TTL_MS = 2 * 60_000; + +/** Maximum credential cache entries. */ +const CREDENTIAL_CACHE_MAX_SIZE = 10_000; + +type CachedCredentials = { + credentials: UserCredentials; + cachedAt: number; +}; + +/** + * Manages per-user API keys and credentials with KMS envelope encryption. + * + * Security model: + * - Credentials are encrypted client-side using KMS before writing to DynamoDB. + * - KMS encryption context includes { user_id } so one user's ciphertext + * cannot be decrypted with another user's context (cross-tenant isolation). + * - DynamoDB never stores plaintext credentials. + * - KMS key rotation is handled by AWS (enabled in CDK). + * - Decrypted values are cached in-memory with a short TTL to avoid + * excessive KMS calls during high-frequency request paths. + */ +export class UserCredentialStore { + private readonly dbClient: HyperionDynamoDBClient; + private readonly kmsClient: KMSClient; + private readonly kmsKeyId: string; + private readonly cache = new Map(); + + constructor(dbClient: HyperionDynamoDBClient, kmsClient: KMSClient, kmsKeyId: string) { + this.dbClient = dbClient; + this.kmsClient = kmsClient; + this.kmsKeyId = kmsKeyId; + } + + /** + * Retrieve and decrypt credentials for a user+agent. + * [claude-infra] Multi-instance: looks up agent-specific, falls back to shared. + * Returns null if the user has no stored credentials. + */ + async getCredentials( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const cacheKey = `${userId}:${agentId}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS) { + return cached.credentials; + } + + const record = await this.dbClient.getUserCredentials(userId, agentId); + if (!record) { + return null; + } + + const credentials = await this.decrypt(record.credentials_blob, userId); + + if (this.cache.size >= CREDENTIAL_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); + return credentials; + } + + /** + * Encrypt and store credentials for a user+agent. + * [claude-infra] Multi-instance: stores with composite key. + */ + async putCredentials( + userId: string, + credentials: UserCredentials, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const blob = await this.encrypt(credentials, userId); + + await this.dbClient.putUserCredentials({ + user_id: userId, + agent_id: agentId, + credentials_blob: blob, + kms_key_id: this.kmsKeyId, + updated_at: new Date().toISOString(), + }); + + const cacheKey = `${userId}:${agentId}`; + this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); + } + + /** + * Delete credentials for a user+agent. + * [claude-infra] Multi-instance: deletes specific agent's credentials. + */ + async deleteCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + await this.dbClient.deleteUserCredentials(userId, agentId); + this.cache.delete(`${userId}:${agentId}`); + } + + /** + * Invalidate the cached credentials for a user+agent. + */ + invalidateCache(userId: string, agentId: string = DEFAULT_AGENT_ID): void { + this.cache.delete(`${userId}:${agentId}`); + } + + clearCache(): void { + this.cache.clear(); + } + + /** + * Encrypt a UserCredentials object using KMS. + * Returns base64-encoded ciphertext. + */ + private async encrypt(credentials: UserCredentials, userId: string): Promise { + const { EncryptCommand } = await import("@aws-sdk/client-kms"); + const plaintext = new TextEncoder().encode(JSON.stringify(credentials)); + + const result = await this.kmsClient.send( + new EncryptCommand({ + KeyId: this.kmsKeyId, + Plaintext: plaintext, + EncryptionContext: { user_id: userId }, + }), + ); + + const ciphertextBlob = (result as { CiphertextBlob?: Uint8Array }).CiphertextBlob; + if (!ciphertextBlob) { + throw new Error(`KMS encryption failed for user ${userId}`); + } + + return uint8ArrayToBase64(ciphertextBlob); + } + + /** + * Decrypt a base64-encoded ciphertext blob back to UserCredentials. + * The encryption context must match what was used during encryption. + */ + private async decrypt(blob: string, userId: string): Promise { + const { DecryptCommand } = await import("@aws-sdk/client-kms"); + const ciphertext = base64ToUint8Array(blob); + + const result = await this.kmsClient.send( + new DecryptCommand({ + CiphertextBlob: ciphertext, + EncryptionContext: { user_id: userId }, + }), + ); + + const plaintext = (result as { Plaintext?: Uint8Array }).Plaintext; + if (!plaintext) { + throw new Error(`KMS decryption failed for user ${userId}`); + } + + return JSON.parse(new TextDecoder().decode(plaintext)) as UserCredentials; + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} From 724adae14f0e0b53c7c0bb3d25321ab8548fc99d Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Wed, 11 Mar 2026 17:51:16 -0400 Subject: [PATCH 09/18] =?UTF-8?q?fix(hyperion):=20address=20review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20tenant=20ID=20derivation,=20atomic=20pairing,?= =?UTF-8?q?=20tool=20key=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive tenant ID from session key via extractTenantId() instead of agent name, preventing cross-tenant collisions when agents share names (P1) - Consume pairing codes atomically with DynamoDB conditional delete to prevent double-redemption race conditions (P1) - Map tool API keys to provider-specific paths (brave_search → search.apiKey, others → search..apiKey) instead of overwriting a single key (P1) - Pass gateway base config (ctx.config) into Hyperion runtime so tenant configs inherit global settings like ACP backend selection (P1) - Add endpointOverride to AgentCore config that applies after SSM loading, so runtime ARNs are still discovered when testing with a local endpoint (P2) - Fix env var restoration in credentials test to delete undefined keys instead of setting them to string "undefined" - Update tests for all changes; all 99 tests pass Co-Authored-By: Claude Opus 4.6 --- extensions/agentcore/index.ts | 2 +- extensions/agentcore/src/config.test.ts | 37 +++++++++++++++++++++++++ extensions/agentcore/src/config.ts | 3 ++ extensions/agentcore/src/runtime.ts | 6 ++-- extensions/hyperion/src/service.ts | 1 + extensions/nova/src/credentials.test.ts | 13 +++++---- src/hyperion/dynamodb-client.ts | 33 ++++++++++++++++++++++ src/hyperion/pairing-store.test.ts | 32 ++++++--------------- src/hyperion/pairing-store.ts | 8 ++---- src/hyperion/tenant-config-loader.ts | 20 ++++++++----- 10 files changed, 111 insertions(+), 44 deletions(-) diff --git a/extensions/agentcore/index.ts b/extensions/agentcore/index.ts index 5441a522b65ae..d59e35d5920f5 100644 --- a/extensions/agentcore/index.ts +++ b/extensions/agentcore/index.ts @@ -35,7 +35,7 @@ const plugin = { configSource: { ssmPrefix, region, - localOverride: pluginConfig.endpoint ? { endpoint: pluginConfig.endpoint } : undefined, + endpointOverride: pluginConfig.endpoint, }, }), ); diff --git a/extensions/agentcore/src/config.test.ts b/extensions/agentcore/src/config.test.ts index cb0a467b405cc..ac54a8b02a370 100644 --- a/extensions/agentcore/src/config.test.ts +++ b/extensions/agentcore/src/config.test.ts @@ -180,6 +180,43 @@ describe("loadAgentCoreConfig", () => { }); }); + // ── endpointOverride (loads from SSM, applies endpoint after) ─────── + + describe("endpointOverride", () => { + it("applies endpoint override without skipping SSM", async () => { + mockSsmSend.mockImplementation((cmd: any) => { + const name = cmd.input?.Name; + if (name?.endsWith("/runtime-arns")) { + return { + Parameter: { + Value: '["arn:aws:agentcore:us-west-2:123:runtime/real"]', + }, + }; + } + return { Parameter: { Value: null } }; + }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + endpointOverride: "https://localhost:9999", + }); + + expect(config.runtimeArns).toEqual(["arn:aws:agentcore:us-west-2:123:runtime/real"]); + expect(config.endpoint).toBe("https://localhost:9999"); + expect(mockSsmSend).toHaveBeenCalled(); + }); + + it("does not set endpoint when endpointOverride is undefined", async () => { + mockSsmSend.mockResolvedValue({ Parameter: { Value: null } }); + + const config = await loadAgentCoreConfig({ + ssmPrefix: "/hyperion/beta/agentcore", + }); + + expect(config.endpoint).toBeUndefined(); + }); + }); + // ── SSM error handling ────────────────────────────────────────────── describe("SSM error handling", () => { diff --git a/extensions/agentcore/src/config.ts b/extensions/agentcore/src/config.ts index 159e150bc77da..9dfa4788425ec 100644 --- a/extensions/agentcore/src/config.ts +++ b/extensions/agentcore/src/config.ts @@ -12,6 +12,8 @@ export type AgentCoreConfigSource = { region?: string; /** Override for local development (skip SSM, use provided config). */ localOverride?: Partial; + /** AgentCore endpoint override — applied after SSM loading (does NOT skip SSM). */ + endpointOverride?: string; }; /** @@ -73,6 +75,7 @@ export async function loadAgentCoreConfig( runtimeArns, memoryNamespacePrefix, defaultModel, + ...(source.endpointOverride ? { endpoint: source.endpointOverride } : {}), }; } diff --git a/extensions/agentcore/src/runtime.ts b/extensions/agentcore/src/runtime.ts index 8ecfeea69d10f..71836190bdb47 100644 --- a/extensions/agentcore/src/runtime.ts +++ b/extensions/agentcore/src/runtime.ts @@ -111,8 +111,10 @@ export class AgentCoreRuntime implements AcpRuntime { const runtimeArn = pickRuntimeArn(this.config.runtimeArns); - // For Hyperion, the OC agentId IS the tenant user_id. - const tenantId = agent; + // [claude-infra] Derive tenant user_id from the Hyperion session key + // (format: "tenant_{userId}:{agentId}:{rest}"), not from `agent` which + // may be a shared logical name like "main" across different tenants. + const tenantId = extractTenantId(sessionKey) ?? agent; // [claude-infra] Multi-instance: extract agent instance ID from session key. const agentId = extractAgentId(sessionKey); diff --git a/extensions/hyperion/src/service.ts b/extensions/hyperion/src/service.ts index ade87eab3d4d2..05e95f92f8d09 100644 --- a/extensions/hyperion/src/service.ts +++ b/extensions/hyperion/src/service.ts @@ -64,6 +64,7 @@ export function createHyperionPluginService( dynamoConfig, docClient, kmsClient, + defaultConfig: ctx.config, }); setHyperionRuntime(runtime); diff --git a/extensions/nova/src/credentials.test.ts b/extensions/nova/src/credentials.test.ts index c2252a30959e3..070fb19987034 100644 --- a/extensions/nova/src/credentials.test.ts +++ b/extensions/nova/src/credentials.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { NovaConfig } from "./types.js"; import { resolveNovaCredentials } from "./credentials.js"; +import type { NovaConfig } from "./types.js"; describe("resolveNovaCredentials", () => { const savedEnv: Record = {}; @@ -17,10 +17,13 @@ describe("resolveNovaCredentials", () => { }); afterEach(() => { - process.env.NOVA_BASE_URL = savedEnv.NOVA_BASE_URL; - process.env.NOVA_API_KEY = savedEnv.NOVA_API_KEY; - process.env.NOVA_USER_ID = savedEnv.NOVA_USER_ID; - process.env.NOVA_DEVICE_ID = savedEnv.NOVA_DEVICE_ID; + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } }); it("resolves credentials from config", () => { diff --git a/src/hyperion/dynamodb-client.ts b/src/hyperion/dynamodb-client.ts index 1786063f1f035..8014c25b9a5c4 100644 --- a/src/hyperion/dynamodb-client.ts +++ b/src/hyperion/dynamodb-client.ts @@ -175,6 +175,39 @@ export class HyperionDynamoDBClient { ); } + /** + * Atomically consume a pairing code: deletes it only if it still exists. + * Returns the deleted item, or null if it was already consumed. + * Uses ConditionExpression to prevent double-redemption races. + */ + async consumePairingCode(code: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + try { + const result = await this.docClient.send( + new DeleteCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + ConditionExpression: "attribute_exists(code)", + ReturnValues: "ALL_OLD", + }), + ); + const item = (result as { Attributes?: PairingCode }).Attributes; + if (!item) { + return null; + } + // DynamoDB TTL is eventually consistent — check expiry explicitly. + if (item.expires_at <= Math.floor(Date.now() / 1000)) { + return null; + } + return item; + } catch (err) { + if ((err as { name?: string }).name === "ConditionalCheckFailedException") { + return null; + } + throw err; + } + } + // -- User Credentials -- [claude-infra] composite key: user_id + agent_id async getUserCredentials( diff --git a/src/hyperion/pairing-store.test.ts b/src/hyperion/pairing-store.test.ts index a6c53fbbe2ca9..311407201c330 100644 --- a/src/hyperion/pairing-store.test.ts +++ b/src/hyperion/pairing-store.test.ts @@ -7,12 +7,14 @@ function createMockDbClient() { return { putPairingCode: vi.fn(), getPairingCode: vi.fn(), + consumePairingCode: vi.fn(), deletePairingCode: vi.fn(), putChannelLink: vi.fn(), deleteChannelLink: vi.fn(), } as unknown as HyperionDynamoDBClient & { putPairingCode: ReturnType; getPairingCode: ReturnType; + consumePairingCode: ReturnType; deletePairingCode: ReturnType; putChannelLink: ReturnType; deleteChannelLink: ReturnType; @@ -105,9 +107,8 @@ describe("HyperionPairingStore", () => { }; it("creates ChannelLink with correct data, inherits agent_id from pairing code", async () => { - dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); dbClient.putChannelLink.mockResolvedValueOnce(undefined); - dbClient.deletePairingCode.mockResolvedValueOnce(undefined); const link = await store.redeemPairingCode({ code: "ABCD1234", @@ -129,9 +130,8 @@ describe("HyperionPairingStore", () => { }); it("normalizes code to uppercase", async () => { - dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); dbClient.putChannelLink.mockResolvedValueOnce(undefined); - dbClient.deletePairingCode.mockResolvedValueOnce(undefined); await store.redeemPairingCode({ code: " abcd1234 ", @@ -139,7 +139,7 @@ describe("HyperionPairingStore", () => { platformUserId: "tg-user-99", }); - expect(dbClient.getPairingCode).toHaveBeenCalledWith("ABCD1234"); + expect(dbClient.consumePairingCode).toHaveBeenCalledWith("ABCD1234"); }); it("returns null for empty code", async () => { @@ -153,8 +153,8 @@ describe("HyperionPairingStore", () => { expect(dbClient.getPairingCode).not.toHaveBeenCalled(); }); - it("returns null if pairing code not found", async () => { - dbClient.getPairingCode.mockResolvedValueOnce(null); + it("returns null if pairing code not found (already consumed)", async () => { + dbClient.consumePairingCode.mockResolvedValueOnce(null); const link = await store.redeemPairingCode({ code: "NONEXIST", @@ -167,7 +167,7 @@ describe("HyperionPairingStore", () => { }); it("returns null if platform doesn't match", async () => { - dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); + dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); const link = await store.redeemPairingCode({ code: "ABCD1234", @@ -178,22 +178,6 @@ describe("HyperionPairingStore", () => { expect(link).toBeNull(); expect(dbClient.putChannelLink).not.toHaveBeenCalled(); }); - - it("deletes consumed code (best effort)", async () => { - dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode); - dbClient.putChannelLink.mockResolvedValueOnce(undefined); - dbClient.deletePairingCode.mockRejectedValueOnce(new Error("Delete failed")); - - const link = await store.redeemPairingCode({ - code: "ABCD1234", - platform: "telegram", - platformUserId: "tg-user-99", - }); - - // Link should still be returned even though delete failed - expect(link).not.toBeNull(); - expect(dbClient.deletePairingCode).toHaveBeenCalledWith("ABCD1234"); - }); }); describe("validatePairingCode", () => { diff --git a/src/hyperion/pairing-store.ts b/src/hyperion/pairing-store.ts index f8bc4b6ba8a65..957ca2c707076 100644 --- a/src/hyperion/pairing-store.ts +++ b/src/hyperion/pairing-store.ts @@ -94,8 +94,9 @@ export class HyperionPairingStore { return null; } - // Fetch and validate the pairing code. - const pairingCode = await this.dbClient.getPairingCode(normalizedCode); + // Atomically consume the pairing code — conditional delete ensures only one + // concurrent redeem succeeds, preventing double-bind race conditions. + const pairingCode = await this.dbClient.consumePairingCode(normalizedCode); if (!pairingCode) { return null; } @@ -118,9 +119,6 @@ export class HyperionPairingStore { await this.dbClient.putChannelLink(channelLink); - // Delete the consumed code (best-effort — TTL will clean up regardless). - await this.dbClient.deletePairingCode(normalizedCode).catch(() => {}); - return channelLink; } diff --git a/src/hyperion/tenant-config-loader.ts b/src/hyperion/tenant-config-loader.ts index 9ed5648e97cde..8875a6af9282d 100644 --- a/src/hyperion/tenant-config-loader.ts +++ b/src/hyperion/tenant-config-loader.ts @@ -215,23 +215,29 @@ export class TenantConfigLoader { } // Inject per-user tool API keys from encrypted credential store. + // Each search provider resolves its key from a provider-specific path: + // brave_search → tools.web.search.apiKey (default/brave) + // gemini/grok/kimi/perplexity → tools.web.search..apiKey if (credentials?.tool_keys) { - const web = config.tools?.web ?? {}; - const search = web.search ?? {}; + const web = { ...config.tools?.web }; + const search = { ...web.search } as Record; for (const [toolName, apiKey] of Object.entries(credentials.tool_keys)) { - if ( - toolName === "brave_search" || + if (toolName === "brave_search") { + search.apiKey = apiKey; + } else if ( toolName === "gemini" || toolName === "grok" || toolName === "kimi" || toolName === "perplexity" ) { - config.tools = { - ...config.tools, - web: { ...web, search: { ...search, apiKey } }, + search[toolName] = { + ...(search[toolName] as Record | undefined), + apiKey, }; } } + web.search = search; + config.tools = { ...config.tools, web }; } // Apply tenant-level skill permissions. From ffc282a5c2d59183d7c29830200216f5c72db90e Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 18:41:40 -0400 Subject: [PATCH 10/18] Updated mode to be token --- config/openclaw.json5 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config/openclaw.json5 b/config/openclaw.json5 index 324965ae2075b..0bab46dff333a 100644 --- a/config/openclaw.json5 +++ b/config/openclaw.json5 @@ -9,9 +9,10 @@ port: 18789, bind: "lan", auth: { - // ALB handles TLS termination; no gateway-level auth needed. + // ALB handles TLS termination; gateway token secures the LAN binding. // WAF rate-limiting + ALB security group restrict access. - mode: "none", + // Token is injected via OPENCLAW_GATEWAY_TOKEN env var in ECS task def. + mode: "token", }, // ALB is a trusted reverse proxy — trust x-forwarded-for for client IP. trustedProxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], @@ -30,7 +31,7 @@ // Logging — structured JSON for CloudWatch ingestion. logging: { - format: "json", + consoleStyle: "json", }, // Disable features not needed in headless server mode. From e9d79893a3735c4487dec2e51ee33692630c5595 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 18:59:54 -0400 Subject: [PATCH 11/18] fix(hyperion): use trusted-proxy auth with Amz-Mons-Idp-Subject header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALB forwards IDP user identity via Amz-Mons-Idp-Subject header. No gateway token needed — ALB SG + WAF provide network-level access control. Regenerate bundled provider auth env vars for amazon-nova extension. Co-Authored-By: Claude Opus 4.6 --- config/openclaw.json5 | 10 ++++++---- extensions/nova/src/channel.ts | 2 +- extensions/nova/src/probe.test.ts | 2 +- extensions/nova/src/probe.ts | 2 +- extensions/nova/src/send.ts | 2 +- .../bundled-provider-auth-env-vars.generated.ts | 1 + 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/config/openclaw.json5 b/config/openclaw.json5 index 0bab46dff333a..bc65f5e9a9c94 100644 --- a/config/openclaw.json5 +++ b/config/openclaw.json5 @@ -9,10 +9,12 @@ port: 18789, bind: "lan", auth: { - // ALB handles TLS termination; gateway token secures the LAN binding. - // WAF rate-limiting + ALB security group restrict access. - // Token is injected via OPENCLAW_GATEWAY_TOKEN env var in ECS task def. - mode: "token", + // ALB is the trusted reverse proxy; user identity arrives in Amz-Mons-Idp-Subject + // header set by the IDP via CloudFront/ALB. WAF + ALB SG restrict network access. + mode: "trusted-proxy", + trustedProxy: { + userHeader: "Amz-Mons-Idp-Subject", + }, }, // ALB is a trusted reverse proxy — trust x-forwarded-for for client IP. trustedProxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], diff --git a/extensions/nova/src/channel.ts b/extensions/nova/src/channel.ts index eaf9b2acd2489..a4647f46b7b3c 100644 --- a/extensions/nova/src/channel.ts +++ b/extensions/nova/src/channel.ts @@ -1,11 +1,11 @@ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; -import type { NovaConfig } from "./types.js"; import { resolveNovaCredentials } from "./credentials.js"; import { novaOnboardingAdapter } from "./onboarding.js"; import { novaOutbound } from "./outbound.js"; import { probeNova } from "./probe.js"; import { sendNovaMessage } from "./send.js"; +import type { NovaConfig } from "./types.js"; type ResolvedNovaAccount = { accountId: string; diff --git a/extensions/nova/src/probe.test.ts b/extensions/nova/src/probe.test.ts index c989d5b298210..0c7d925ba5071 100644 --- a/extensions/nova/src/probe.test.ts +++ b/extensions/nova/src/probe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { NovaConfig } from "./types.js"; import { probeNova } from "./probe.js"; +import type { NovaConfig } from "./types.js"; describe("probeNova", () => { it("returns ok for valid config with default baseUrl", () => { diff --git a/extensions/nova/src/probe.ts b/extensions/nova/src/probe.ts index 981ebb2390ec9..5e4dcc7858ba7 100644 --- a/extensions/nova/src/probe.ts +++ b/extensions/nova/src/probe.ts @@ -1,5 +1,5 @@ -import type { NovaConfig } from "./types.js"; import { resolveNovaCredentials } from "./credentials.js"; +import type { NovaConfig } from "./types.js"; export type ProbeNovaResult = { ok: boolean; diff --git a/extensions/nova/src/send.ts b/extensions/nova/src/send.ts index edeed228d17f9..eaf5f5ff044a7 100644 --- a/extensions/nova/src/send.ts +++ b/extensions/nova/src/send.ts @@ -1,7 +1,7 @@ import WebSocket from "ws"; -import type { NovaConfig } from "./types.js"; import { getActiveNovaConnection } from "./connection.js"; import { resolveNovaCredentials } from "./credentials.js"; +import type { NovaConfig } from "./types.js"; export type SendNovaMessageOpts = { cfg: { channels?: { nova?: NovaConfig } }; diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 416036b28ea5b..ddf60bc06f61c 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -1,6 +1,7 @@ // Auto-generated by scripts/generate-bundled-provider-auth-env-vars.mjs. Do not edit directly. export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + "amazon-nova": ["NOVA_API_KEY"], anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], byteplus: ["BYTEPLUS_API_KEY"], chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], From 93c18de1e9002c3ae998e00c05540e415ba77031 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 19:03:33 -0400 Subject: [PATCH 12/18] fix(hyperion): add AWS SDK deps to root for src/hyperion/ type resolution src/hyperion/ lives in the root workspace but imports @aws-sdk/client-dynamodb, @aws-sdk/client-kms, and @aws-sdk/lib-dynamodb. These were only declared in extensions/hyperion/package.json and not hoisted, causing tsgo failures. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 + pnpm-lock.yaml | 733 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 614 insertions(+), 122 deletions(-) diff --git a/package.json b/package.json index e7142b76a54f8..dd9fc6e978268 100644 --- a/package.json +++ b/package.json @@ -684,6 +684,9 @@ "dependencies": { "@agentclientprotocol/sdk": "0.16.1", "@aws-sdk/client-bedrock": "^3.1011.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-kms": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0", "@clack/prompts": "^1.1.0", "@homebridge/ciao": "^1.3.5", "@line/bot-sdk": "^10.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e7586716b31..70851a20368d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,15 @@ importers: '@aws-sdk/client-bedrock': specifier: ^3.1011.0 version: 3.1011.0 + '@aws-sdk/client-dynamodb': + specifier: ^3.0.0 + version: 3.1013.0 + '@aws-sdk/client-kms': + specifier: ^3.0.0 + version: 3.1013.0 + '@aws-sdk/lib-dynamodb': + specifier: ^3.0.0 + version: 3.1013.0(@aws-sdk/client-dynamodb@3.1013.0) '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 @@ -238,8 +247,23 @@ importers: specifier: 0.3.0 version: 0.3.0(zod@4.3.6) + extensions/agentcore: + dependencies: + '@aws-sdk/client-bedrock-agentcore': + specifier: ^3.0.0 + version: 3.1013.0 + '@aws-sdk/client-ssm': + specifier: ^3.0.0 + version: 3.1013.0 + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/amazon-bedrock: {} + extensions/amazon-nova: {} + extensions/anthropic: {} extensions/bluebubbles: @@ -360,6 +384,22 @@ importers: extensions/huggingface: {} + extensions/hyperion: + dependencies: + '@aws-sdk/client-dynamodb': + specifier: ^3.0.0 + version: 3.1013.0 + '@aws-sdk/client-kms': + specifier: ^3.0.0 + version: 3.1013.0 + '@aws-sdk/lib-dynamodb': + specifier: ^3.0.0 + version: 3.1013.0(@aws-sdk/client-dynamodb@3.1013.0) + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/imessage: {} extensions/irc: @@ -478,6 +518,19 @@ importers: specifier: ^4.3.6 version: 4.3.6 + extensions/nova: + dependencies: + ws: + specifier: ^8.18.0 + version: 8.19.0 + devDependencies: + '@types/ws': + specifier: ^8.5.14 + version: 8.18.1 + openclaw: + specifier: workspace:* + version: link:../.. + extensions/nvidia: {} extensions/ollama: {} @@ -720,6 +773,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-bedrock-agentcore@3.1013.0': + resolution: {integrity: sha512-b8Q3C3R444KM2idZtISUN2a1WTBbYOqmojvZs4wuRxiCHCPmio/gii0YaVkg8iE1Fudk1rjc1T2HPwHSC1ytbg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1004.0': resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} engines: {node: '>=20.0.0'} @@ -736,14 +793,30 @@ packages: resolution: {integrity: sha512-p9OM3otePseffnPMz6adeLzI82D+LNtH15uzGD4KTJWVo4ZD7V9nRcx7lWb166WIBGWS2UnH4TCxZ5J/qDpXcA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-dynamodb@3.1013.0': + resolution: {integrity: sha512-AcXLwdk9T5p/46V/QB6wX34q6+48lqIS185o20QPyuXScUcNHnvlNrzMa8KHisAFYLxZF6vwiBcsbJIgaoOzKw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-kms@3.1013.0': + resolution: {integrity: sha512-cDrwc6s7RLcRDR990383FdgpvkpjWhTrD1YyNYo1xZMmtgZ3aghAq2HBhs3adlxToXxEWmGCZn4HWcwPi+CjoQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1000.0': resolution: {integrity: sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-ssm@3.1013.0': + resolution: {integrity: sha512-Ecy8u741Q6FsOjRVaUgPkywF6RFcDbH1nUZ7NnT9Y1EY5ChXxxcjBydk1MdxRRAf/FIXfhjVH+XShqHKOEN29Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.20': resolution: {integrity: sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.22': + resolution: {integrity: sha512-lY6g5L95jBNgOUitUhfV2N/W+i08jHEl3xuLODYSQH5Sf50V+LkVYBSyZRLtv2RyuXZXiV7yQ+acpswK1tlrOA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.3': resolution: {integrity: sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg==} engines: {node: '>=20.0.0'} @@ -752,34 +825,74 @@ packages: resolution: {integrity: sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.20': + resolution: {integrity: sha512-vI0QN96DFx3g9AunfOWF3CS4cMkqFiR/WM/FyP9QHr5rZ2dKPkYwP3tCgAOvGuu9CXI7dC1vU2FVUuZ+tfpNvQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.20': resolution: {integrity: sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.22': + resolution: {integrity: sha512-aS/81smalpe7XDnuQfOq4LIPuaV2PRKU2aMTrHcqO5BD4HwO5kESOHNcec2AYfBtLtIDqgF6RXisgBnfK/jt0w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.20': resolution: {integrity: sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.22': + resolution: {integrity: sha512-rpF8fBT0LllMDp78s62aL2A/8MaccjyJ0ORzqu+ZADeECLSrrCWIeeXsuRam+pxiAMkI1uIyDZJmgLGdadkPXw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.20': resolution: {integrity: sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.22': + resolution: {integrity: sha512-u33CO9zeNznlVSg9tWTCRYxaGkqr1ufU6qeClpmzAabXZa8RZxQoVXxL5T53oZJFzQYj+FImORCSsi7H7B77gQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.21': resolution: {integrity: sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.23': + resolution: {integrity: sha512-U8tyLbLOZItuVWTH0ay9gWo4xMqZwqQbg1oMzdU4FQSkTpqXemm4X0uoKBR6llqAStgBp30ziKFJHTA43l4qMw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.18': resolution: {integrity: sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.20': + resolution: {integrity: sha512-QRfk7GbA4/HDRjhP3QYR6QBr/QKreVoOzvvlRHnOuGgYJkeoPgPY3LAI1kK1ZMgZ4hH9KiGp757/ntol+INAig==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.20': resolution: {integrity: sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.22': + resolution: {integrity: sha512-4vqlSaUbBj4aNPVKfB6yXuIQ2Z2mvLfIGba2OzzF6zUkN437/PGWsxBU2F8QPSFHti6seckvyCXidU3H+R8NvQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.20': resolution: {integrity: sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.22': + resolution: {integrity: sha512-/wN1CYg2rVLhW8/jLxMWacQrkpaynnL+4j/Z+e6X1PfoE6NiC0BeOw3i0JmtZrKun85wNV5GmspvuWJihfeeUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/dynamodb-codec@3.972.23': + resolution: {integrity: sha512-wPudIRKouJwd34Blw4+fFEZirMgLYY9dR7WbDlsEVm/8yLOaSGhGnMj8idf9Av1Vtu9UUeJpnp9jpWwZNHBi1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/endpoint-cache@3.972.4': + resolution: {integrity: sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.10': resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==} engines: {node: '>=20.0.0'} @@ -788,10 +901,20 @@ packages: resolution: {integrity: sha512-2IrLrOruRr1NhTK0vguBL1gCWv1pu4bf4KaqpsA+/vCJpFEbvXFawn71GvCzk1wyjnDUsemtKypqoKGv4cSGbA==} engines: {node: '>=20.0.0'} + '@aws-sdk/lib-dynamodb@3.1013.0': + resolution: {integrity: sha512-mUifF7LGBDSZF11d/L81VZc4YgNxlWnUkBwe6JKWwvQaBF2Ay6OUsgxcz8lt7dDs2+IOQ+x1xOa3OnofxWX+XA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.1013.0 + '@aws-sdk/middleware-bucket-endpoint@3.972.6': resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.972.8': + resolution: {integrity: sha512-S0oXx1QbSpMDBMJn4P0hOxW8ieGAdRT+G9NbL+ESWkkoCGf9D++fKYD2fyBGtIy88OrP7wgECpXgGLAcGpIj0A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.7': resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==} engines: {node: '>=20.0.0'} @@ -836,6 +959,10 @@ packages: resolution: {integrity: sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.23': + resolution: {integrity: sha512-HQu8QoqGZZTvg0Spl9H39QTsSMFwgu+8yz/QGKndXFLk9FZMiCiIgBCVlTVKMDvVbgqIzD9ig+/HmXsIL2Rb+g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.12': resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==} engines: {node: '>= 14.0.0'} @@ -848,6 +975,10 @@ packages: resolution: {integrity: sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.12': + resolution: {integrity: sha512-KLdQGJPSm98uLINolQ0Tol8OAbk7g0Y7zplHJ1K83vbMIH13aoCvR6Tho66xueW4l4aZlEgVGLWBnD8ifUMsGQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.8': resolution: {integrity: sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==} engines: {node: '>=20.0.0'} @@ -872,6 +1003,10 @@ packages: resolution: {integrity: sha512-WSfBVDQ9uyh1GCR+DxxgHEvAKv+beMIlSeJ2pMAG1HTci340+xbtz1VFwnTJ5qCxrMi+E4dyDMiSAhDvHnq73A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1013.0': + resolution: {integrity: sha512-IL1c54UvbuERrs9oLm5rvkzMciwhhpn1FL0SlC3XUMoLlFhdBsWJgQKK8O5fsQLxbFVqjbjFx9OBkrn44X9PHw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.6': resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} engines: {node: '>=20.0.0'} @@ -880,6 +1015,12 @@ packages: resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-dynamodb@3.996.2': + resolution: {integrity: sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.1003.0 + '@aws-sdk/util-endpoints@3.996.5': resolution: {integrity: sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==} engines: {node: '>=20.0.0'} @@ -904,10 +1045,23 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.973.9': + resolution: {integrity: sha512-jeFqqp8KD/P5O+qeKxyGeu7WEVIZFNprnkaDjGmBOjwxYwafCBhpxTgV1TlW6L8e76Vh/siNylNmN/OmSIFBUQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.972.11': resolution: {integrity: sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.14': + resolution: {integrity: sha512-G/Yd8Bnnyh8QrqLf8jWJbixEnScUFW24e/wOBGYdw1Cl4r80KX/DvHyM2GVZ2vTp7J4gTEr8IXJlTadA8+UfuQ==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -2879,10 +3033,6 @@ packages: resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.11': - resolution: {integrity: sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.12': resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} engines: {node: '>=18.0.0'} @@ -2951,26 +3101,14 @@ packages: resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.25': - resolution: {integrity: sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.26': resolution: {integrity: sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.42': - resolution: {integrity: sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.43': resolution: {integrity: sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.14': - resolution: {integrity: sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.15': resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} engines: {node: '>=18.0.0'} @@ -2983,10 +3121,6 @@ packages: resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.16': - resolution: {integrity: sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.5.0': resolution: {integrity: sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==} engines: {node: '>=18.0.0'} @@ -3019,10 +3153,6 @@ packages: resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.5': - resolution: {integrity: sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==} - engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.6': resolution: {integrity: sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==} engines: {node: '>=18.0.0'} @@ -3059,18 +3189,10 @@ packages: resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.41': - resolution: {integrity: sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.42': resolution: {integrity: sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.44': - resolution: {integrity: sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.45': resolution: {integrity: sha512-q5dOqqfTgUcLe38TAGiFn9srToKj2YCHJ34QGOLzM+xYLLA+qRZv7N+33kl1MERVusue36ZHnlNaNEvY/PzSrw==} engines: {node: '>=18.0.0'} @@ -3111,6 +3233,10 @@ packages: resolution: {integrity: sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==} engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.2.13': + resolution: {integrity: sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==} + engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.2': resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} @@ -5145,6 +5271,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mnemonist@0.38.3: + resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -5272,6 +5401,9 @@ packages: resolution: {integrity: sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==} engines: {node: '>= 10.12.0'} + obliterator@1.6.1: + resolution: {integrity: sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -6728,6 +6860,54 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-bedrock-agentcore@3.1013.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/credential-provider-node': 3.972.23 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.9 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-bedrock-runtime@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6849,26 +7029,26 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.8 '@aws-sdk/util-user-agent-node': 3.973.7 '@smithy/config-resolver': 4.4.11 - '@smithy/core': 3.23.11 + '@smithy/core': 3.23.12 '@smithy/fetch-http-handler': 5.3.15 '@smithy/hash-node': 4.2.12 '@smithy/invalid-dependency': 4.2.12 '@smithy/middleware-content-length': 4.2.12 - '@smithy/middleware-endpoint': 4.4.25 - '@smithy/middleware-retry': 4.4.42 - '@smithy/middleware-serde': 4.2.14 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 '@smithy/middleware-stack': 4.2.12 '@smithy/node-config-provider': 4.3.12 - '@smithy/node-http-handler': 4.4.16 + '@smithy/node-http-handler': 4.5.0 '@smithy/protocol-http': 5.3.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.6 '@smithy/types': 4.13.1 '@smithy/url-parser': 4.2.12 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.41 - '@smithy/util-defaults-mode-node': 4.2.44 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 '@smithy/util-endpoints': 3.3.3 '@smithy/util-middleware': 4.2.12 '@smithy/util-retry': 4.2.12 @@ -6922,6 +7102,97 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-dynamodb@3.1013.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/credential-provider-node': 3.972.23 + '@aws-sdk/dynamodb-codec': 3.972.23 + '@aws-sdk/middleware-endpoint-discovery': 3.972.8 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.9 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-kms@3.1013.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/credential-provider-node': 3.972.23 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.9 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.1000.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -6982,15 +7253,76 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.20': + '@aws-sdk/client-ssm@3.1013.0': dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/credential-provider-node': 3.972.23 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/region-config-resolver': 3.972.8 '@aws-sdk/types': 3.973.6 - '@aws-sdk/xml-builder': 3.972.11 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.9 + '@smithy/config-resolver': 4.4.11 '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 '@smithy/node-config-provider': 4.3.12 - '@smithy/property-provider': 4.2.12 + '@smithy/node-http-handler': 4.5.0 '@smithy/protocol-http': 5.3.12 - '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.20': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.11 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.973.22': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.14 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 '@smithy/smithy-client': 4.12.6 '@smithy/types': 4.13.1 '@smithy/util-base64': 4.3.2 @@ -7011,6 +7343,14 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7024,6 +7364,19 @@ snapshots: '@smithy/util-stream': 4.5.20 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.22': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/types': 3.973.6 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7043,6 +7396,25 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.22': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/credential-provider-env': 3.972.20 + '@aws-sdk/credential-provider-http': 3.972.22 + '@aws-sdk/credential-provider-login': 3.972.22 + '@aws-sdk/credential-provider-process': 3.972.20 + '@aws-sdk/credential-provider-sso': 3.972.22 + '@aws-sdk/credential-provider-web-identity': 3.972.22 + '@aws-sdk/nested-clients': 3.996.12 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7056,6 +7428,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.22': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/nested-clients': 3.996.12 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.972.21': dependencies: '@aws-sdk/credential-provider-env': 3.972.18 @@ -7073,6 +7458,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.23': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.20 + '@aws-sdk/credential-provider-http': 3.972.22 + '@aws-sdk/credential-provider-ini': 3.972.22 + '@aws-sdk/credential-provider-process': 3.972.20 + '@aws-sdk/credential-provider-sso': 3.972.22 + '@aws-sdk/credential-provider-web-identity': 3.972.22 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.972.18': dependencies: '@aws-sdk/core': 3.973.20 @@ -7082,6 +7484,15 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7095,6 +7506,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.22': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/nested-clients': 3.996.12 + '@aws-sdk/token-providers': 3.1013.0 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7107,6 +7531,32 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.22': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/nested-clients': 3.996.12 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/dynamodb-codec@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.22 + '@smithy/core': 3.23.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@aws-sdk/endpoint-cache@3.972.4': + dependencies: + mnemonist: 0.38.3 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.10': dependencies: '@aws-sdk/types': 3.973.6 @@ -7121,6 +7571,16 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/lib-dynamodb@3.1013.0(@aws-sdk/client-dynamodb@3.1013.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.1013.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/util-dynamodb': 3.996.2(@aws-sdk/client-dynamodb@3.1013.0) + '@smithy/core': 3.23.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.6': dependencies: '@aws-sdk/types': 3.973.6 @@ -7131,6 +7591,15 @@ snapshots: '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 + '@aws-sdk/middleware-endpoint-discovery@3.972.8': + dependencies: + '@aws-sdk/endpoint-cache': 3.972.4 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.7': dependencies: '@aws-sdk/types': 3.973.6 @@ -7230,6 +7699,17 @@ snapshots: '@smithy/util-retry': 4.2.12 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.12': dependencies: '@aws-sdk/types': 3.973.6 @@ -7303,6 +7783,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.996.12': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.22 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.9 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -7367,6 +7890,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1013.0': + dependencies: + '@aws-sdk/core': 3.973.22 + '@aws-sdk/nested-clients': 3.996.12 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.6': dependencies: '@smithy/types': 4.13.1 @@ -7376,6 +7911,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@aws-sdk/util-dynamodb@3.996.2(@aws-sdk/client-dynamodb@3.1013.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.1013.0 + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.996.5': dependencies: '@aws-sdk/types': 3.973.6 @@ -7411,12 +7951,27 @@ snapshots: '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.973.9': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.23 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.11': dependencies: '@smithy/types': 4.13.1 fast-xml-parser: 5.5.6 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.14': + dependencies: + '@smithy/types': 4.13.1 + fast-xml-parser: 5.5.6 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@azure/abort-controller@2.1.2': @@ -9555,19 +10110,6 @@ snapshots: '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/core@3.23.11': - dependencies: - '@smithy/protocol-http': 5.3.12 - '@smithy/types': 4.13.1 - '@smithy/url-parser': 4.2.12 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.12 - '@smithy/util-stream': 4.5.20 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - '@smithy/core@3.23.12': dependencies: '@smithy/protocol-http': 5.3.12 @@ -9679,17 +10221,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.25': - dependencies: - '@smithy/core': 3.23.12 - '@smithy/middleware-serde': 4.2.15 - '@smithy/node-config-provider': 4.3.12 - '@smithy/shared-ini-file-loader': 4.4.7 - '@smithy/types': 4.13.1 - '@smithy/url-parser': 4.2.12 - '@smithy/util-middleware': 4.2.12 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.26': dependencies: '@smithy/core': 3.23.12 @@ -9701,18 +10232,6 @@ snapshots: '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.42': - dependencies: - '@smithy/node-config-provider': 4.3.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/service-error-classification': 4.2.12 - '@smithy/smithy-client': 4.12.6 - '@smithy/types': 4.13.1 - '@smithy/util-middleware': 4.2.12 - '@smithy/util-retry': 4.2.12 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - '@smithy/middleware-retry@4.4.43': dependencies: '@smithy/node-config-provider': 4.3.12 @@ -9725,13 +10244,6 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.14': - dependencies: - '@smithy/core': 3.23.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@smithy/middleware-serde@4.2.15': dependencies: '@smithy/core': 3.23.12 @@ -9751,14 +10263,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.16': - dependencies: - '@smithy/abort-controller': 4.2.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/querystring-builder': 4.2.12 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@smithy/node-http-handler@4.5.0': dependencies: '@smithy/abort-controller': 4.2.12 @@ -9808,16 +10312,6 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/smithy-client@4.12.5': - dependencies: - '@smithy/core': 3.23.12 - '@smithy/middleware-endpoint': 4.4.26 - '@smithy/middleware-stack': 4.2.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/types': 4.13.1 - '@smithy/util-stream': 4.5.20 - tslib: 2.8.1 - '@smithy/smithy-client@4.12.6': dependencies: '@smithy/core': 3.23.12 @@ -9866,13 +10360,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.41': - dependencies: - '@smithy/property-provider': 4.2.12 - '@smithy/smithy-client': 4.12.6 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.42': dependencies: '@smithy/property-provider': 4.2.12 @@ -9880,16 +10367,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.44': - dependencies: - '@smithy/config-resolver': 4.4.11 - '@smithy/credential-provider-imds': 4.2.12 - '@smithy/node-config-provider': 4.3.12 - '@smithy/property-provider': 4.2.12 - '@smithy/smithy-client': 4.12.6 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.45': dependencies: '@smithy/config-resolver': 4.4.11 @@ -9952,6 +10429,12 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@smithy/util-waiter@4.2.13': + dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@smithy/uuid@1.1.2': dependencies: tslib: 2.8.1 @@ -12177,6 +12660,10 @@ snapshots: dependencies: minipass: 7.1.3 + mnemonist@0.38.3: + dependencies: + obliterator: 1.6.1 + module-details-from-path@1.0.4: {} mpg123-decoder@1.0.3: @@ -12344,6 +12831,8 @@ snapshots: object-path@0.11.8: {} + obliterator@1.6.1: {} + obug@2.1.1: {} octokit@5.0.5: From 6bf907cac0b59c5d21a3ff2dc28b45a7002cfe83 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 22:43:44 -0400 Subject: [PATCH 13/18] Make hyperion/agentcore extensions self-contained for Docker runtime Move hyperion lib code into extensions/hyperion/src/lib/ so the extension doesn't depend on src/hyperion/ (which isn't copied to the Docker runtime image). Update all cross-extension imports to use relative sibling paths instead of reaching into src/. Co-Authored-By: Claude Opus 4.6 --- extensions/agentcore/src/runtime.ts | 3 +- extensions/hyperion/src/env.ts | 2 +- extensions/hyperion/src/globals.ts | 2 +- .../src/lib/channel-identity-resolver.ts | 132 ++++++++ .../hyperion/src/lib/dynamodb-client.ts | 265 +++++++++++++++++ extensions/hyperion/src/lib/index.ts | 28 ++ extensions/hyperion/src/lib/pairing-store.ts | 162 ++++++++++ extensions/hyperion/src/lib/runtime.ts | 90 ++++++ .../hyperion/src/lib/session-manager.ts | 158 ++++++++++ .../hyperion/src/lib/tenant-config-loader.ts | 281 ++++++++++++++++++ extensions/hyperion/src/lib/types.ts | 204 +++++++++++++ .../hyperion/src/lib/user-credential-store.ts | 186 ++++++++++++ extensions/hyperion/src/service.ts | 2 +- 13 files changed, 1510 insertions(+), 5 deletions(-) create mode 100644 extensions/hyperion/src/lib/channel-identity-resolver.ts create mode 100644 extensions/hyperion/src/lib/dynamodb-client.ts create mode 100644 extensions/hyperion/src/lib/index.ts create mode 100644 extensions/hyperion/src/lib/pairing-store.ts create mode 100644 extensions/hyperion/src/lib/runtime.ts create mode 100644 extensions/hyperion/src/lib/session-manager.ts create mode 100644 extensions/hyperion/src/lib/tenant-config-loader.ts create mode 100644 extensions/hyperion/src/lib/types.ts create mode 100644 extensions/hyperion/src/lib/user-credential-store.ts diff --git a/extensions/agentcore/src/runtime.ts b/extensions/agentcore/src/runtime.ts index 71836190bdb47..02bea74f7d8c3 100644 --- a/extensions/agentcore/src/runtime.ts +++ b/extensions/agentcore/src/runtime.ts @@ -17,9 +17,8 @@ import type { AcpRuntimeTurnInput, } from "openclaw/plugin-sdk/acpx"; import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; -import { extractTenantId, extractAgentId } from "../../../src/hyperion/session-manager.js"; -import { DEFAULT_AGENT_ID } from "../../../src/hyperion/types.js"; import { hasHyperionRuntime, getHyperionRuntime } from "../../hyperion/src/globals.js"; +import { extractTenantId, extractAgentId, DEFAULT_AGENT_ID } from "../../hyperion/src/lib/index.js"; import type { AgentCoreHandleState, AgentCoreRuntimeConfig } from "./types.js"; export const AGENTCORE_BACKEND_ID = "agentcore"; diff --git a/extensions/hyperion/src/env.ts b/extensions/hyperion/src/env.ts index 68feb5ad05742..877c2bb900eff 100644 --- a/extensions/hyperion/src/env.ts +++ b/extensions/hyperion/src/env.ts @@ -1,4 +1,4 @@ -import type { HyperionDynamoDBConfig } from "../../../src/hyperion/types.js"; +import type { HyperionDynamoDBConfig } from "./lib/types.js"; /** * Resolve the Hyperion stage from environment or plugin config. diff --git a/extensions/hyperion/src/globals.ts b/extensions/hyperion/src/globals.ts index 0d49818feaa04..82eca09d590d9 100644 --- a/extensions/hyperion/src/globals.ts +++ b/extensions/hyperion/src/globals.ts @@ -1,4 +1,4 @@ -import type { HyperionRuntime } from "../../../src/hyperion/index.js"; +import type { HyperionRuntime } from "./lib/index.js"; let hyperionRuntime: HyperionRuntime | null = null; diff --git a/extensions/hyperion/src/lib/channel-identity-resolver.ts b/extensions/hyperion/src/lib/channel-identity-resolver.ts new file mode 100644 index 0000000000000..b5b921580bceb --- /dev/null +++ b/extensions/hyperion/src/lib/channel-identity-resolver.ts @@ -0,0 +1,132 @@ +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { TenantConfigLoader } from "./tenant-config-loader.js"; +import { + DEFAULT_AGENT_ID, + type CachedChannelIdentity, + type ChannelIdentityResolution, + type ChannelLink, + type HyperionPlatform, +} from "./types.js"; + +/** Identity cache TTL: 5 minutes. */ +const IDENTITY_CACHE_TTL_MS = 5 * 60_000; + +/** Maximum identity cache entries. */ +const IDENTITY_CACHE_MAX_SIZE = 50_000; + +/** + * Resolves external channel messages to internal tenant identities. + * + * This is the critical path for inbound webhook messages: + * External message arrives → resolve platform_user_id → get user_id → load config + * + * Replaces OpenClaw's in-memory allowFrom/pairing matching with DynamoDB lookups. + * The channel_config table maps (platform, platform_user_id) → user_id. + * + * Caching: in-memory with 5-minute TTL per (platform, platform_user_id). + */ +export class ChannelIdentityResolver { + private readonly dbClient: HyperionDynamoDBClient; + private readonly configLoader: TenantConfigLoader; + private readonly cache = new Map(); + + constructor(dbClient: HyperionDynamoDBClient, configLoader: TenantConfigLoader) { + this.dbClient = dbClient; + this.configLoader = configLoader; + } + + /** + * Resolve an inbound channel message to a tenant. + * + * Flow: + * 1. Look up channel_config by (platform, platform_user_id) + * 2. Extract user_id from the link + * 3. Load the full tenant config (with all channel configs assembled) + * + * @returns ChannelIdentityResolution or null if not paired. + */ + async resolve( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const channelLink = await this.getChannelLink(platform, platformUserId); + if (!channelLink) { + return null; + } + + // [claude-infra] Multi-instance: load config for the specific agent instance + // the channel is bound to. + const agentId = channelLink.agent_id || DEFAULT_AGENT_ID; + const config = await this.configLoader.loadTenantConfig(channelLink.user_id, agentId); + + return { + user_id: channelLink.user_id, + agent_id: agentId, + channelLink, + config, + }; + } + + /** + * Resolve only the user_id for a given platform identity. + * Lighter-weight than full resolve() when you don't need the config. + */ + async resolveUserId(platform: HyperionPlatform, platformUserId: string): Promise { + const channelLink = await this.getChannelLink(platform, platformUserId); + return channelLink?.user_id ?? null; + } + + /** + * Get all channel links for a specific user. + * Useful for the portal "Connected Channels" UI. + */ + async getLinksForUser(userId: string): Promise { + return this.dbClient.getChannelLinksForUser(userId); + } + + /** + * Invalidate the identity cache for a specific channel. + * Call this when a channel is paired or unpaired. + */ + invalidateCache(platform: HyperionPlatform, platformUserId: string): void { + this.cache.delete(this.cacheKey(platform, platformUserId)); + } + + /** + * Clear the entire identity cache. + */ + clearCache(): void { + this.cache.clear(); + } + + private async getChannelLink( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const key = this.cacheKey(platform, platformUserId); + const cached = this.cache.get(key); + if (cached && Date.now() - cached.cachedAt < IDENTITY_CACHE_TTL_MS) { + return cached.channelLink; + } + + const channelLink = await this.dbClient.getChannelLink(platform, platformUserId); + if (!channelLink) { + return null; + } + + // Evict oldest if full. + if (this.cache.size >= IDENTITY_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { channelLink, cachedAt: Date.now() }); + return channelLink; + } + + private cacheKey(platform: HyperionPlatform, platformUserId: string): string { + return `${platform}:${platformUserId}`; + } +} diff --git a/extensions/hyperion/src/lib/dynamodb-client.ts b/extensions/hyperion/src/lib/dynamodb-client.ts new file mode 100644 index 0000000000000..8014c25b9a5c4 --- /dev/null +++ b/extensions/hyperion/src/lib/dynamodb-client.ts @@ -0,0 +1,265 @@ +import { + DEFAULT_AGENT_ID, + type ChannelLink, + type HyperionDynamoDBConfig, + type HyperionPlatform, + type PairingCode, + type TenantConfig, + type UserCredentialsRecord, +} from "./types.js"; + +/** + * Minimal DynamoDB document client interface. + * Accepts any AWS SDK v3 DynamoDBDocumentClient-compatible implementation. + */ +export type DynamoDBDocClient = { + send(command: unknown): Promise; +}; + +/** + * Wraps DynamoDB operations for Hyperion's three tables. + * Designed to work with AWS SDK v3 DynamoDBDocumentClient. + */ +export class HyperionDynamoDBClient { + private readonly config: HyperionDynamoDBConfig; + private readonly docClient: DynamoDBDocClient; + + constructor(config: HyperionDynamoDBConfig, docClient: DynamoDBDocClient) { + this.config = config; + this.docClient = docClient; + } + + // -- Tenant Config -- [claude-infra] composite key: user_id + agent_id + + async getTenantConfig( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.tenantConfigTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + const item = (result as { Item?: TenantConfig }).Item; + return item ?? null; + } + + async listTenantAgents(userId: string): Promise { + const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new QueryCommand({ + TableName: this.config.tenantConfigTableName, + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }), + ); + return (result as { Items?: TenantConfig[] }).Items ?? []; + } + + async putTenantConfig(tenantConfig: TenantConfig): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.tenantConfigTableName, + Item: { + ...tenantConfig, + agent_id: tenantConfig.agent_id || DEFAULT_AGENT_ID, + updated_at: new Date().toISOString(), + }, + }), + ); + } + + async deleteTenantConfig(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.tenantConfigTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + } + + // -- Channel Config -- + + async getChannelLink( + platform: HyperionPlatform, + platformUserId: string, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.channelConfigTableName, + Key: { platform, platform_user_id: platformUserId }, + }), + ); + const item = (result as { Item?: ChannelLink }).Item; + return item ?? null; + } + + async getChannelLinksForUser(userId: string): Promise { + const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new QueryCommand({ + TableName: this.config.channelConfigTableName, + IndexName: this.config.channelConfigUserIdIndexName, + KeyConditionExpression: "user_id = :uid", + ExpressionAttributeValues: { ":uid": userId }, + }), + ); + const items = (result as { Items?: ChannelLink[] }).Items; + return items ?? []; + } + + async putChannelLink(channelLink: ChannelLink): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.channelConfigTableName, + Item: channelLink, + }), + ); + } + + async deleteChannelLink(platform: HyperionPlatform, platformUserId: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.channelConfigTableName, + Key: { platform, platform_user_id: platformUserId }, + }), + ); + } + + // -- Pairing Codes -- + + async getPairingCode(code: string): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + }), + ); + const item = (result as { Item?: PairingCode }).Item; + if (!item) { + return null; + } + // DynamoDB TTL is eventually consistent — check expiry explicitly. + if (item.expires_at <= Math.floor(Date.now() / 1000)) { + return null; + } + return item; + } + + async putPairingCode(pairingCode: PairingCode): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.pairingCodesTableName, + Item: pairingCode, + ConditionExpression: "attribute_not_exists(code)", + }), + ); + } + + async deletePairingCode(code: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + }), + ); + } + + /** + * Atomically consume a pairing code: deletes it only if it still exists. + * Returns the deleted item, or null if it was already consumed. + * Uses ConditionExpression to prevent double-redemption races. + */ + async consumePairingCode(code: string): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + try { + const result = await this.docClient.send( + new DeleteCommand({ + TableName: this.config.pairingCodesTableName, + Key: { code }, + ConditionExpression: "attribute_exists(code)", + ReturnValues: "ALL_OLD", + }), + ); + const item = (result as { Attributes?: PairingCode }).Attributes; + if (!item) { + return null; + } + // DynamoDB TTL is eventually consistent — check expiry explicitly. + if (item.expires_at <= Math.floor(Date.now() / 1000)) { + return null; + } + return item; + } catch (err) { + if ((err as { name?: string }).name === "ConditionalCheckFailedException") { + return null; + } + throw err; + } + } + + // -- User Credentials -- [claude-infra] composite key: user_id + agent_id + + async getUserCredentials( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); + // Try agent-specific credentials first, then shared. + const result = await this.docClient.send( + new GetCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + const item = (result as { Item?: UserCredentialsRecord }).Item; + if (item) { + return item; + } + + // Fall back to shared credentials if agent-specific not found. + if (agentId !== "__shared__") { + const sharedResult = await this.docClient.send( + new GetCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: "__shared__" }, + }), + ); + return (sharedResult as { Item?: UserCredentialsRecord }).Item ?? null; + } + return null; + } + + async putUserCredentials(record: UserCredentialsRecord): Promise { + const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new PutCommand({ + TableName: this.config.userCredentialsTableName, + Item: { + ...record, + agent_id: record.agent_id || DEFAULT_AGENT_ID, + }, + }), + ); + } + + async deleteUserCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); + await this.docClient.send( + new DeleteCommand({ + TableName: this.config.userCredentialsTableName, + Key: { user_id: userId, agent_id: agentId }, + }), + ); + } +} diff --git a/extensions/hyperion/src/lib/index.ts b/extensions/hyperion/src/lib/index.ts new file mode 100644 index 0000000000000..71d8d56551c62 --- /dev/null +++ b/extensions/hyperion/src/lib/index.ts @@ -0,0 +1,28 @@ +export { DEFAULT_AGENT_ID } from "./types.js"; +export type { + ChannelIdentityResolution, + ChannelLink, + ChannelRuntimeConfig, + CachedChannelIdentity, + CachedTenantConfig, + HyperionDynamoDBConfig, + HyperionPlatform, + PairingCode, + TenantConfig, +} from "./types.js"; +export { HyperionDynamoDBClient } from "./dynamodb-client.js"; +export type { DynamoDBDocClient } from "./dynamodb-client.js"; +export { TenantConfigLoader, TenantNotFoundError } from "./tenant-config-loader.js"; +export { ChannelIdentityResolver } from "./channel-identity-resolver.js"; +export { HyperionPairingStore } from "./pairing-store.js"; +export { + buildPortalSessionKey, + buildChannelSessionKey, + buildTenantMemoryNamespace, + extractAgentId, + extractInnerSessionKey, + extractTenantId, + isSessionForAgent, + isSessionForTenant, +} from "./session-manager.js"; +export { createHyperionRuntime, type HyperionRuntime } from "./runtime.js"; diff --git a/extensions/hyperion/src/lib/pairing-store.ts b/extensions/hyperion/src/lib/pairing-store.ts new file mode 100644 index 0000000000000..957ca2c707076 --- /dev/null +++ b/extensions/hyperion/src/lib/pairing-store.ts @@ -0,0 +1,162 @@ +import crypto from "node:crypto"; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { + DEFAULT_AGENT_ID, + type ChannelLink, + type ChannelRuntimeConfig, + type HyperionPlatform, + type PairingCode, +} from "./types.js"; + +const PAIRING_CODE_LENGTH = 8; +const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const PAIRING_CODE_TTL_SECONDS = 5 * 60; // 5 minutes + +/** + * DynamoDB-backed pairing store for Hyperion. + * + * Replaces OpenClaw's file-based pairing store (src/pairing/pairing-store.ts) with + * a serverless implementation using the pairing_codes DynamoDB table. + * + * Flow: + * 1. User clicks "Connect Telegram" in portal → generatePairingCode() + * 2. User sends `/connect ` to bot on Telegram → redeemPairingCode() + * 3. Code is validated, channel link is created, code is deleted + * + * Key differences from OpenClaw's file-based store: + * - No file locks needed — DynamoDB provides atomic conditional writes + * - No pruning needed — DynamoDB TTL auto-deletes expired codes + * - No max-pending limit needed — TTL-based expiry prevents unbounded growth + * - Pairing is user-initiated (portal → external), not external-initiated + */ +export class HyperionPairingStore { + private readonly dbClient: HyperionDynamoDBClient; + + constructor(dbClient: HyperionDynamoDBClient) { + this.dbClient = dbClient; + } + + /** + * Generate a pairing code for a user to connect a specific channel. + * Called from the portal when user clicks "Connect ". + * + * @returns The generated code, or null if code generation failed after retries. + */ + // [claude-infra] Multi-instance: agentId specifies which agent the channel binds to. + async generatePairingCode( + userId: string, + platform: HyperionPlatform, + agentId: string = DEFAULT_AGENT_ID, + meta?: Record, + ): Promise { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = randomCode(); + const pairingCode: PairingCode = { + code, + user_id: userId, + agent_id: agentId, + platform, + created_at: new Date().toISOString(), + expires_at: Math.floor(Date.now() / 1000) + PAIRING_CODE_TTL_SECONDS, + ...(meta ? { meta } : {}), + }; + + try { + await this.dbClient.putPairingCode(pairingCode); + return code; + } catch (err) { + // ConditionalCheckFailedException means code already exists — retry. + if ((err as { name?: string }).name === "ConditionalCheckFailedException") { + continue; + } + throw err; + } + } + return null; + } + + /** + * Redeem a pairing code and create the channel link. + * Called when a webhook arrives with `/connect `. + * + * @returns The created ChannelLink, or null if the code is invalid/expired. + */ + async redeemPairingCode(params: { + code: string; + platform: HyperionPlatform; + platformUserId: string; + channelAccountId?: string; + channelConfig?: ChannelRuntimeConfig; + }): Promise { + const normalizedCode = params.code.trim().toUpperCase(); + if (!normalizedCode) { + return null; + } + + // Atomically consume the pairing code — conditional delete ensures only one + // concurrent redeem succeeds, preventing double-bind race conditions. + const pairingCode = await this.dbClient.consumePairingCode(normalizedCode); + if (!pairingCode) { + return null; + } + + // Verify platform matches. + if (pairingCode.platform !== params.platform) { + return null; + } + + // [claude-infra] Multi-instance: channel link inherits agent_id from pairing code. + const channelLink: ChannelLink = { + platform: params.platform, + platform_user_id: params.platformUserId, + user_id: pairingCode.user_id, + agent_id: pairingCode.agent_id || DEFAULT_AGENT_ID, + paired_at: new Date().toISOString(), + channel_account_id: params.channelAccountId ?? "default", + channel_config: params.channelConfig ?? {}, + }; + + await this.dbClient.putChannelLink(channelLink); + + return channelLink; + } + + /** + * Validate a pairing code without consuming it. + * Useful for showing confirmation before completing pairing. + */ + async validatePairingCode(code: string, platform: HyperionPlatform): Promise { + const normalizedCode = code.trim().toUpperCase(); + if (!normalizedCode) { + return null; + } + + const pairingCode = await this.dbClient.getPairingCode(normalizedCode); + if (!pairingCode) { + return null; + } + if (pairingCode.platform !== platform) { + return null; + } + + return pairingCode; + } + + /** + * Disconnect a channel link. + * Called from the portal when user clicks "Disconnect ". + */ + async disconnectChannel(platform: HyperionPlatform, platformUserId: string): Promise { + await this.dbClient.deleteChannelLink(platform, platformUserId); + } +} + +function randomCode(): string { + let out = ""; + for (let i = 0; i < PAIRING_CODE_LENGTH; i++) { + const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length); + out += PAIRING_CODE_ALPHABET[idx]; + } + return out; +} diff --git a/extensions/hyperion/src/lib/runtime.ts b/extensions/hyperion/src/lib/runtime.ts new file mode 100644 index 0000000000000..f6f844f528aa8 --- /dev/null +++ b/extensions/hyperion/src/lib/runtime.ts @@ -0,0 +1,90 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { ChannelIdentityResolver } from "./channel-identity-resolver.js"; +import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; +import { HyperionPairingStore } from "./pairing-store.js"; +import { TenantConfigLoader } from "./tenant-config-loader.js"; +import type { HyperionDynamoDBConfig } from "./types.js"; +import { UserCredentialStore, type KMSClient } from "./user-credential-store.js"; + +/** + * The complete Hyperion runtime — all services wired together. + */ +export type HyperionRuntime = { + /** DynamoDB operations for all tables. */ + dbClient: HyperionDynamoDBClient; + /** Loads per-tenant OpenClawConfig from DynamoDB (replaces loadConfig). */ + configLoader: TenantConfigLoader; + /** Resolves inbound webhooks to tenant identities (replaces allowFrom/pairing match). */ + identityResolver: ChannelIdentityResolver; + /** Manages pairing codes and channel linking (replaces file-based pairing store). */ + pairingStore: HyperionPairingStore; + /** Manages per-user encrypted credentials (API keys, bot tokens). */ + credentialStore: UserCredentialStore; +}; + +/** + * Create the full Hyperion runtime with a single call. + * + * Usage: + * ```ts + * import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; + * import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + * import { KMSClient } from "@aws-sdk/client-kms"; + * import { createHyperionRuntime } from "./hyperion/index.js"; + * + * const ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); + * const kmsClient = new KMSClient({ region: "us-east-1" }); + * const runtime = createHyperionRuntime({ + * dynamoConfig: { + * region: "us-east-1", + * tenantConfigTableName: "Hyperion-prod-tenant-config", + * channelConfigTableName: "Hyperion-prod-channel-config", + * pairingCodesTableName: "Hyperion-prod-pairing-codes", + * userCredentialsTableName: "Hyperion-prod-user-credentials", + * credentialsKmsKeyId: "alias/hyperion-prod-credentials", + * channelConfigUserIdIndexName: "user_id-index", + * }, + * docClient: ddbClient, + * kmsClient: kmsClient, + * }); + * + * // Portal SSE path (credentials auto-injected into config): + * const config = await runtime.configLoader.loadTenantConfig(userId); + * + * // Store user credentials (encrypted at rest via KMS): + * await runtime.credentialStore.putCredentials(userId, { + * model_keys: { openai: "sk-..." }, + * tool_keys: { brave_search: "BSA..." }, + * }); + * + * // Webhook path: + * const identity = await runtime.identityResolver.resolve("telegram", "12345"); + * + * // Pairing: + * const code = await runtime.pairingStore.generatePairingCode(userId, "telegram"); + * ``` + */ +export function createHyperionRuntime(params: { + dynamoConfig: HyperionDynamoDBConfig; + docClient: DynamoDBDocClient; + kmsClient: KMSClient; + defaultConfig?: Partial; +}): HyperionRuntime { + const dbClient = new HyperionDynamoDBClient(params.dynamoConfig, params.docClient); + const credentialStore = new UserCredentialStore( + dbClient, + params.kmsClient, + params.dynamoConfig.credentialsKmsKeyId, + ); + const configLoader = new TenantConfigLoader(dbClient, params.defaultConfig, credentialStore); + const identityResolver = new ChannelIdentityResolver(dbClient, configLoader); + const pairingStore = new HyperionPairingStore(dbClient); + + return { + dbClient, + configLoader, + identityResolver, + pairingStore, + credentialStore, + }; +} diff --git a/extensions/hyperion/src/lib/session-manager.ts b/extensions/hyperion/src/lib/session-manager.ts new file mode 100644 index 0000000000000..4aab48d39f538 --- /dev/null +++ b/extensions/hyperion/src/lib/session-manager.ts @@ -0,0 +1,158 @@ +import { DEFAULT_AGENT_ID, type HyperionPlatform } from "./types.js"; + +/** + * Tenant-scoped session key management for Hyperion. + * [claude-infra] Multi-instance: session keys now include agent_id. + * + * OpenClaw's sessions are identified by session keys (src/channels/session.ts, + * src/routing/session-key.ts). In single-tenant mode, session keys are simple + * channel identifiers like "telegram:12345" or "main". + * + * For multi-tenant Hyperion, all session keys are namespaced with the tenant's + * user_id and agent_id to ensure complete isolation between tenants and agents: + * + * Single-tenant OpenClaw: "main" + * Multi-tenant Hyperion: "tenant_user123:main:main" + * + * Single-tenant OpenClaw: "telegram:12345" + * Multi-tenant Hyperion: "tenant_user123:main:telegram:12345" + * + * Format: tenant_{userId}:{agentId}:{rest} + */ + +const TENANT_PREFIX = "tenant_"; +const SEPARATOR = ":"; + +/** + * Build a tenant-scoped session key for portal (synchronous) interactions. + * [claude-infra] Multi-instance: includes agentId. + * + * @param userId - Internal Hyperion user ID + * @param agentId - Agent instance ID (default: "main") + * @returns Scoped session key like "tenant_user123:main:main" + */ +export function buildPortalSessionKey(userId: string, agentId: string = DEFAULT_AGENT_ID): string { + return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}main`; +} + +/** + * Build a tenant-scoped session key for an external channel interaction. + * [claude-infra] Multi-instance: includes agentId. + * + * @param userId - Internal Hyperion user ID + * @param agentId - Agent instance ID (default: "main") + * @param platform - External platform identifier + * @param platformUserId - User's ID on the external platform + * @param threadId - Optional thread/conversation ID for threaded sessions + * @returns Scoped session key like "tenant_user123:main:telegram:98765" + */ +export function buildChannelSessionKey( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + platform: HyperionPlatform, + platformUserId: string, + threadId?: string, +): string { + const base = `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}${platform}${SEPARATOR}${platformUserId}`; + if (threadId) { + return `${base}${SEPARATOR}${threadId}`; + } + return base; +} + +/** + * Extract the tenant user_id from a scoped session key. + * + * @returns The user_id, or null if the key is not tenant-scoped. + */ +export function extractTenantId(sessionKey: string): string | null { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return null; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + const separatorIdx = afterPrefix.indexOf(SEPARATOR); + if (separatorIdx < 0) { + return null; + } + return afterPrefix.slice(0, separatorIdx); +} + +/** + * Extract the agent_id from a scoped session key. + * [claude-infra] Multi-instance support. + * + * Format: tenant_{userId}:{agentId}:{rest} + * @returns The agent_id, or DEFAULT_AGENT_ID if not found. + */ +export function extractAgentId(sessionKey: string): string { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return DEFAULT_AGENT_ID; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + const firstSep = afterPrefix.indexOf(SEPARATOR); + if (firstSep < 0) { + return DEFAULT_AGENT_ID; + } + const afterUserId = afterPrefix.slice(firstSep + 1); + const secondSep = afterUserId.indexOf(SEPARATOR); + if (secondSep < 0) { + return afterUserId || DEFAULT_AGENT_ID; + } + return afterUserId.slice(0, secondSep) || DEFAULT_AGENT_ID; +} + +/** + * Extract the inner session key (without tenant prefix and agent_id). + * This is what gets passed to OpenClaw's session internals. + * [claude-infra] Multi-instance: strips both tenant_ prefix and agentId. + * + * @returns The inner key, or the original key if not tenant-scoped. + */ +export function extractInnerSessionKey(sessionKey: string): string { + if (!sessionKey.startsWith(TENANT_PREFIX)) { + return sessionKey; + } + const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); + // Skip userId + const firstSep = afterPrefix.indexOf(SEPARATOR); + if (firstSep < 0) { + return sessionKey; + } + const afterUserId = afterPrefix.slice(firstSep + 1); + // Skip agentId + const secondSep = afterUserId.indexOf(SEPARATOR); + if (secondSep < 0) { + return afterUserId; + } + return afterUserId.slice(secondSep + 1); +} + +/** + * Check if a session key belongs to a specific tenant. + */ +export function isSessionForTenant(sessionKey: string, userId: string): boolean { + return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}`); +} + +/** + * Check if a session key belongs to a specific tenant+agent. + * [claude-infra] Multi-instance support. + */ +export function isSessionForAgent( + sessionKey: string, + userId: string, + agentId: string = DEFAULT_AGENT_ID, +): boolean { + return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}`); +} + +/** + * Build the AgentCore memory namespace for a tenant+agent. + * [claude-infra] Multi-instance: each agent instance has isolated memory. + */ +export function buildTenantMemoryNamespace( + userId: string, + agentId: string = DEFAULT_AGENT_ID, +): string { + return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}`; +} diff --git a/extensions/hyperion/src/lib/tenant-config-loader.ts b/extensions/hyperion/src/lib/tenant-config-loader.ts new file mode 100644 index 0000000000000..54484831f3dd6 --- /dev/null +++ b/extensions/hyperion/src/lib/tenant-config-loader.ts @@ -0,0 +1,281 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; + +// ChannelsConfig is the channels section of OpenClawConfig. +// Defined inline to avoid importing from OC internals. +type ChannelsConfig = NonNullable; +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { + DEFAULT_AGENT_ID, + type CachedTenantConfig, + type ChannelLink, + type TenantConfig, + type UserCredentials, +} from "./types.js"; +import type { UserCredentialStore } from "./user-credential-store.js"; + +/** Config cache TTL: 1 minute. */ +const CONFIG_CACHE_TTL_MS = 60_000; + +/** Maximum cache entries to prevent unbounded growth. */ +const CONFIG_CACHE_MAX_SIZE = 10_000; + +/** + * Loads and assembles OpenClawConfig for a specific tenant from DynamoDB. + * + * Replaces OpenClaw's filesystem-based `loadConfig()` (src/config/io.ts) with + * a multi-tenant DynamoDB-backed implementation: + * + * 1. Fetches tenant_config (profile, model, tools, skills, etc.) + * 2. Fetches all channel_config links for the user via GSI + * 3. Assembles the OpenClawConfig.channels section dynamically + * 4. Merges tenant base config with channel configs into a complete OpenClawConfig + * + * Caching: in-memory LRU with 1-minute TTL per tenant. + */ +export class TenantConfigLoader { + private readonly dbClient: HyperionDynamoDBClient; + private readonly credentialStore: UserCredentialStore | null; + private readonly cache = new Map(); + private readonly defaultConfig: Partial; + + constructor( + dbClient: HyperionDynamoDBClient, + defaultConfig?: Partial, + credentialStore?: UserCredentialStore, + ) { + this.dbClient = dbClient; + this.defaultConfig = defaultConfig ?? {}; + this.credentialStore = credentialStore ?? null; + } + + /** + * Load the full OpenClawConfig for a tenant+agent, with caching. + * [claude-infra] Multi-instance: agentId defaults to "main". + */ + async loadTenantConfig( + tenantId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const cacheKey = `${tenantId}:${agentId}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < CONFIG_CACHE_TTL_MS) { + return cached.config; + } + + const config = await this.buildTenantConfig(tenantId, agentId); + + // Evict oldest entries if cache is full. + if (this.cache.size >= CONFIG_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(cacheKey, { config, cachedAt: Date.now() }); + return config; + } + + /** + * Invalidate the cached config for a tenant+agent. + * Call this when tenant config or channel links are updated. + * [claude-infra] Multi-instance: agentId defaults to "main". + */ + invalidateCache(tenantId: string, agentId: string = DEFAULT_AGENT_ID): void { + this.cache.delete(`${tenantId}:${agentId}`); + } + + /** + * Clear the entire config cache. + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Build the full OpenClawConfig for a tenant by reading DynamoDB. + */ + private async buildTenantConfig( + tenantId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + // Fetch tenant config, channel links, and credentials in parallel. + // [claude-infra] Multi-instance: config + credentials keyed by (userId, agentId), + // channel links filtered to matching agent_id. + const [tenantConfig, allChannelLinks, credentials] = await Promise.all([ + this.dbClient.getTenantConfig(tenantId, agentId), + this.dbClient.getChannelLinksForUser(tenantId), + this.credentialStore?.getCredentials(tenantId, agentId) ?? Promise.resolve(null), + ]); + // Filter channel links to only those bound to this agent instance. + const channelLinks = allChannelLinks.filter( + (link) => (link.agent_id || DEFAULT_AGENT_ID) === agentId, + ); + + if (!tenantConfig) { + throw new TenantNotFoundError(tenantId); + } + + const channels = this.assembleChannelsConfig(channelLinks); + return this.mergeConfig(tenantConfig, channels, credentials); + } + + /** + * Assemble OpenClaw's ChannelsConfig from the tenant's channel links. + * + * Each channel link produces an entry under the appropriate platform key. + * Multiple links for the same platform use the multi-account pattern: + * channels.telegram.accounts[accountId] = { ...config, allowFrom: [platformUserId] } + */ + private assembleChannelsConfig(channelLinks: ChannelLink[]): ChannelsConfig { + const channels: ChannelsConfig = {}; + + for (const link of channelLinks) { + const platform = link.platform; + const accountId = link.channel_account_id || "default"; + + if (!channels[platform]) { + channels[platform] = { + enabled: true, + accounts: {}, + defaultAccount: accountId, + }; + } + + channels[platform]!.accounts[accountId] = this.buildAccountConfig(link); + } + + return channels; + } + + /** + * Build a per-account channel config from a channel link. + * The platform_user_id is automatically added to allowFrom + * to authorize the linked external identity. + */ + private buildAccountConfig(link: ChannelLink): Record { + const runtimeConfig = link.channel_config ?? {}; + return { + ...runtimeConfig, + // Authorize this specific external user for DM access. + allowFrom: [link.platform_user_id], + // DM policy is "open" for paired users — they've already been verified. + dmPolicy: runtimeConfig.dmPolicy ?? "open", + }; + } + + /** + * Merge tenant-level settings with the assembled channel config + * and decrypted credentials into a complete OpenClawConfig. + */ + private mergeConfig( + tenant: TenantConfig, + channels: ChannelsConfig, + credentials: UserCredentials | null, + ): OpenClawConfig { + const config: OpenClawConfig = { + ...this.defaultConfig, + channels, + }; + + // Apply tenant-level agent configuration (model, profile, custom instructions). + // OC agents are under config.agents.list — each entry is an AgentConfig. + const baseAgent = config.agents?.list?.[0] ?? { id: "default" }; + const agentPatches: Record = {}; + + if (tenant.model) { + agentPatches.model = { primary: tenant.model }; + } + if (tenant.custom_instructions) { + agentPatches.customInstructions = tenant.custom_instructions; + } + if (tenant.profile) { + Object.assign(agentPatches, tenant.profile); + } + + if (Object.keys(agentPatches).length > 0) { + config.agents = { + ...config.agents, + list: [{ ...baseAgent, ...agentPatches }], + }; + } + + // Inject per-user model provider API keys from encrypted credential store. + if (credentials?.model_keys) { + const providers = { ...config.models?.providers }; + for (const [provider, apiKey] of Object.entries(credentials.model_keys)) { + providers[provider] = { ...providers[provider], apiKey }; + } + config.models = { ...config.models, providers }; + } + + // Apply tenant-level tool permissions. + if (tenant.tools) { + config.tools = { + ...config.tools, + allow: tenant.tools, + }; + } + + // Inject per-user tool API keys from encrypted credential store. + // Each search provider resolves its key from a provider-specific path: + // brave_search → tools.web.search.apiKey (default/brave) + // gemini/grok/kimi/perplexity → tools.web.search..apiKey + if (credentials?.tool_keys) { + const web = { ...config.tools?.web }; + const search = { ...web.search } as Record; + for (const [toolName, apiKey] of Object.entries(credentials.tool_keys)) { + if (toolName === "brave_search") { + search.apiKey = apiKey; + } else if ( + toolName === "gemini" || + toolName === "grok" || + toolName === "kimi" || + toolName === "perplexity" + ) { + search[toolName] = { + ...(search[toolName] as Record | undefined), + apiKey, + }; + } + } + web.search = search; + config.tools = { ...config.tools, web }; + } + + // Apply tenant-level skill permissions. + if (tenant.skills) { + config.skills = { + ...config.skills, + allowBundled: tenant.skills, + }; + } + + // Inject per-user channel bot tokens into assembled channel accounts. + if (credentials?.channel_tokens) { + for (const [platform, token] of Object.entries(credentials.channel_tokens)) { + const platformConfig = channels[platform]; + if (platformConfig?.accounts) { + for (const account of Object.values(platformConfig.accounts)) { + account.botToken = token; + } + } + } + } + + return config; + } +} + +/** + * Thrown when a tenant_id cannot be found in the tenant_config table. + */ +export class TenantNotFoundError extends Error { + public readonly tenantId: string; + + constructor(tenantId: string) { + super(`Tenant not found: ${tenantId}`); + this.name = "TenantNotFoundError"; + this.tenantId = tenantId; + } +} diff --git a/extensions/hyperion/src/lib/types.ts b/extensions/hyperion/src/lib/types.ts new file mode 100644 index 0000000000000..04dd0358603e2 --- /dev/null +++ b/extensions/hyperion/src/lib/types.ts @@ -0,0 +1,204 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; + +/** + * Supported external channel platforms for Hyperion. + */ +export type HyperionPlatform = "telegram" | "slack" | "whatsapp" | "discord"; + +/** + * Default agent ID for single-instance users (backwards compatible). + * [claude-infra] Multi-instance support + */ +export const DEFAULT_AGENT_ID = "main"; + +/** + * A channel link record stored in the channel_config DynamoDB table. + * Maps an external platform identity to an internal Hyperion user_id. + * + * Table schema: + * PK: platform (HyperionPlatform) + * SK: platform_user_id (string) + * GSI1PK: user_id (string) + */ +export type ChannelLink = { + /** External platform identifier (e.g., "telegram", "slack"). */ + platform: HyperionPlatform; + /** The user's identity on the external platform (e.g., Telegram user ID, Slack team+user). */ + platform_user_id: string; + /** Internal Hyperion user ID this channel is linked to. */ + user_id: string; + /** Agent instance this channel is bound to. Default: "main". [claude-infra] */ + agent_id: string; + /** ISO timestamp when the channel was paired. */ + paired_at: string; + /** Account ID within the channel (e.g., bot token alias). */ + channel_account_id: string; + /** Platform-specific channel runtime configuration. */ + channel_config: ChannelRuntimeConfig; +}; + +/** + * Platform-specific runtime configuration stored per channel link. + * Subset of OpenClaw's per-account channel config, relevant for multi-tenant operation. + */ +export type ChannelRuntimeConfig = { + /** DM policy for this channel link. */ + dmPolicy?: "pairing" | "open" | "disabled"; + /** Streaming mode for message delivery. */ + streaming?: "off" | "partial" | "block" | "progress"; + /** Max text chunk size for message splitting. */ + textChunkLimit?: number; + /** Reply threading mode. */ + replyToMode?: string; + /** Group message policy. */ + groupPolicy?: Record; + /** Bot token or Secrets Manager ARN reference. */ + credentialRef?: string; + /** Additional platform-specific settings. */ + [key: string]: unknown; +}; + +/** + * Tenant configuration stored in the tenant_config DynamoDB table. + * + * Table schema: + * PK: user_id (string), SK: agent_id (string) [claude-infra] + */ +export type TenantConfig = { + /** Internal Hyperion user ID. */ + user_id: string; + /** Agent instance ID. Default: "main". [claude-infra] */ + agent_id: string; + /** User's display name. */ + display_name?: string; + /** User's preferred model configuration. */ + model?: string; + /** User's custom instructions for the agent. */ + custom_instructions?: string; + /** User's subscription plan. */ + plan?: "free" | "pro" | "enterprise"; + /** Usage limits for the tenant. */ + limits?: { + messages_per_day?: number; + messages_per_month?: number; + }; + /** Agent profile/persona settings. */ + profile?: Record; + /** Enabled tools for this tenant. */ + tools?: string[]; + /** Enabled skills for this tenant. */ + skills?: string[]; + /** ISO timestamp when config was last updated. */ + updated_at?: string; +}; + +/** + * A pairing code record stored in the pairing_codes DynamoDB table. + * + * Table schema: + * PK: code (string) + * TTL: expires_at (number, epoch seconds) + */ +export type PairingCode = { + /** The human-friendly pairing code. */ + code: string; + /** Internal user ID that initiated the pairing. */ + user_id: string; + /** Agent instance to bind the channel to. Default: "main". [claude-infra] */ + agent_id: string; + /** Target platform for the pairing. */ + platform: HyperionPlatform; + /** ISO timestamp when the code was created. */ + created_at: string; + /** TTL attribute — epoch seconds when this code expires. */ + expires_at: number; + /** Optional metadata from the pairing request. */ + meta?: Record; +}; + +/** + * Result of resolving an inbound channel message to a tenant. + */ +export type ChannelIdentityResolution = { + /** The resolved internal user ID. */ + user_id: string; + /** The resolved agent instance ID. [claude-infra] */ + agent_id: string; + /** The channel link record. */ + channelLink: ChannelLink; + /** The assembled OpenClawConfig for this tenant+agent. */ + config: OpenClawConfig; +}; + +/** + * Per-user credentials stored encrypted in the user_credentials DynamoDB table. + * Each field is optional — users only store the keys they actually use. + * + * Values are encrypted at rest via KMS envelope encryption with + * encryption context { user_id: "" } to ensure per-tenant isolation. + */ +export type UserCredentials = { + /** Model provider API keys (e.g., openai, anthropic, google). */ + model_keys?: Record; + /** Tool API keys (e.g., brave_search, firecrawl, perplexity). */ + tool_keys?: Record; + /** Channel bot tokens (e.g., telegram, discord, slack). */ + channel_tokens?: Record; + /** Arbitrary additional credentials for custom tools/plugins. */ + custom?: Record; +}; + +/** + * Raw record stored in the user_credentials DynamoDB table. + * The credentials_blob field contains KMS-encrypted JSON of UserCredentials. + */ +export type UserCredentialsRecord = { + /** Internal Hyperion user ID (PK). */ + user_id: string; + /** Agent instance ID (SK). Default: "main". [claude-infra] */ + agent_id: string; + /** KMS-encrypted credentials blob (base64-encoded ciphertext). */ + credentials_blob: string; + /** KMS key ID used for encryption (for key rotation tracking). */ + kms_key_id: string; + /** ISO timestamp when credentials were last updated. */ + updated_at: string; +}; + +/** + * Configuration for the Hyperion DynamoDB integration. + */ +export type HyperionDynamoDBConfig = { + /** AWS region for DynamoDB. */ + region: string; + /** Tenant config table name. */ + tenantConfigTableName: string; + /** Channel config table name. */ + channelConfigTableName: string; + /** Pairing codes table name. */ + pairingCodesTableName: string; + /** User credentials table name. */ + userCredentialsTableName: string; + /** KMS key ID (ARN or alias) for credential encryption. */ + credentialsKmsKeyId: string; + /** GSI name for user_id lookups on channel_config. */ + channelConfigUserIdIndexName: string; + /** Optional DynamoDB endpoint override (for local development). */ + endpoint?: string; +}; + +/** + * Cache entry for tenant configs with TTL. + */ +export type CachedTenantConfig = { + config: OpenClawConfig; + cachedAt: number; +}; + +/** + * Cache entry for channel identity resolution with TTL. + */ +export type CachedChannelIdentity = { + channelLink: ChannelLink; + cachedAt: number; +}; diff --git a/extensions/hyperion/src/lib/user-credential-store.ts b/extensions/hyperion/src/lib/user-credential-store.ts new file mode 100644 index 0000000000000..03a13b25ebe0f --- /dev/null +++ b/extensions/hyperion/src/lib/user-credential-store.ts @@ -0,0 +1,186 @@ +import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; +import { DEFAULT_AGENT_ID, type UserCredentials } from "./types.js"; + +/** + * Minimal KMS client interface. + * Accepts any AWS SDK v3 KMSClient-compatible implementation. + */ +export type KMSClient = { + send(command: unknown): Promise; +}; + +/** Credential cache TTL: 2 minutes (shorter than config cache for security). */ +const CREDENTIAL_CACHE_TTL_MS = 2 * 60_000; + +/** Maximum credential cache entries. */ +const CREDENTIAL_CACHE_MAX_SIZE = 10_000; + +type CachedCredentials = { + credentials: UserCredentials; + cachedAt: number; +}; + +/** + * Manages per-user API keys and credentials with KMS envelope encryption. + * + * Security model: + * - Credentials are encrypted client-side using KMS before writing to DynamoDB. + * - KMS encryption context includes { user_id } so one user's ciphertext + * cannot be decrypted with another user's context (cross-tenant isolation). + * - DynamoDB never stores plaintext credentials. + * - KMS key rotation is handled by AWS (enabled in CDK). + * - Decrypted values are cached in-memory with a short TTL to avoid + * excessive KMS calls during high-frequency request paths. + */ +export class UserCredentialStore { + private readonly dbClient: HyperionDynamoDBClient; + private readonly kmsClient: KMSClient; + private readonly kmsKeyId: string; + private readonly cache = new Map(); + + constructor(dbClient: HyperionDynamoDBClient, kmsClient: KMSClient, kmsKeyId: string) { + this.dbClient = dbClient; + this.kmsClient = kmsClient; + this.kmsKeyId = kmsKeyId; + } + + /** + * Retrieve and decrypt credentials for a user+agent. + * [claude-infra] Multi-instance: looks up agent-specific, falls back to shared. + * Returns null if the user has no stored credentials. + */ + async getCredentials( + userId: string, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const cacheKey = `${userId}:${agentId}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS) { + return cached.credentials; + } + + const record = await this.dbClient.getUserCredentials(userId, agentId); + if (!record) { + return null; + } + + const credentials = await this.decrypt(record.credentials_blob, userId); + + if (this.cache.size >= CREDENTIAL_CACHE_MAX_SIZE) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); + return credentials; + } + + /** + * Encrypt and store credentials for a user+agent. + * [claude-infra] Multi-instance: stores with composite key. + */ + async putCredentials( + userId: string, + credentials: UserCredentials, + agentId: string = DEFAULT_AGENT_ID, + ): Promise { + const blob = await this.encrypt(credentials, userId); + + await this.dbClient.putUserCredentials({ + user_id: userId, + agent_id: agentId, + credentials_blob: blob, + kms_key_id: this.kmsKeyId, + updated_at: new Date().toISOString(), + }); + + const cacheKey = `${userId}:${agentId}`; + this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); + } + + /** + * Delete credentials for a user+agent. + * [claude-infra] Multi-instance: deletes specific agent's credentials. + */ + async deleteCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { + await this.dbClient.deleteUserCredentials(userId, agentId); + this.cache.delete(`${userId}:${agentId}`); + } + + /** + * Invalidate the cached credentials for a user+agent. + */ + invalidateCache(userId: string, agentId: string = DEFAULT_AGENT_ID): void { + this.cache.delete(`${userId}:${agentId}`); + } + + clearCache(): void { + this.cache.clear(); + } + + /** + * Encrypt a UserCredentials object using KMS. + * Returns base64-encoded ciphertext. + */ + private async encrypt(credentials: UserCredentials, userId: string): Promise { + const { EncryptCommand } = await import("@aws-sdk/client-kms"); + const plaintext = new TextEncoder().encode(JSON.stringify(credentials)); + + const result = await this.kmsClient.send( + new EncryptCommand({ + KeyId: this.kmsKeyId, + Plaintext: plaintext, + EncryptionContext: { user_id: userId }, + }), + ); + + const ciphertextBlob = (result as { CiphertextBlob?: Uint8Array }).CiphertextBlob; + if (!ciphertextBlob) { + throw new Error(`KMS encryption failed for user ${userId}`); + } + + return uint8ArrayToBase64(ciphertextBlob); + } + + /** + * Decrypt a base64-encoded ciphertext blob back to UserCredentials. + * The encryption context must match what was used during encryption. + */ + private async decrypt(blob: string, userId: string): Promise { + const { DecryptCommand } = await import("@aws-sdk/client-kms"); + const ciphertext = base64ToUint8Array(blob); + + const result = await this.kmsClient.send( + new DecryptCommand({ + CiphertextBlob: ciphertext, + EncryptionContext: { user_id: userId }, + }), + ); + + const plaintext = (result as { Plaintext?: Uint8Array }).Plaintext; + if (!plaintext) { + throw new Error(`KMS decryption failed for user ${userId}`); + } + + return JSON.parse(new TextDecoder().decode(plaintext)) as UserCredentials; + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/extensions/hyperion/src/service.ts b/extensions/hyperion/src/service.ts index 05e95f92f8d09..dce89133728bd 100644 --- a/extensions/hyperion/src/service.ts +++ b/extensions/hyperion/src/service.ts @@ -2,9 +2,9 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { KMSClient } from "@aws-sdk/client-kms"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; -import { createHyperionRuntime } from "../../../src/hyperion/index.js"; import { buildDynamoConfig, resolveStage } from "./env.js"; import { clearHyperionRuntime, setHyperionRuntime } from "./globals.js"; +import { createHyperionRuntime } from "./lib/index.js"; export type HyperionPluginConfig = { stage?: string; From 05240b3d18d46b6f79854d3144b6eadb779c6be5 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 23:10:54 -0400 Subject: [PATCH 14/18] Strip unused extensions from Docker runtime image When OPENCLAW_EXTENSIONS is set, remove all other extensions from the runtime-assets stage. This prevents OOM from loading 40+ upstream extensions that aren't needed in our deployment. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Dockerfile b/Dockerfile index fa97f83323a26..0ce2f86837a2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,8 +96,20 @@ RUN pnpm ui:build # Prune dev dependencies and strip build-only metadata before copying # runtime assets into the final image. FROM build AS runtime-assets +ARG OPENCLAW_EXTENSIONS="" RUN CI=true pnpm prune --prod && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete +# When OPENCLAW_EXTENSIONS is set, remove all other extensions from the +# runtime image to avoid OOM from loading unused plugins at startup. +RUN if [ -n "$OPENCLAW_EXTENSIONS" ] && [ -d extensions ]; then \ + for dir in extensions/*/; do \ + name="$(basename "$dir")"; \ + case " $OPENCLAW_EXTENSIONS " in \ + *" $name "*) ;; \ + *) rm -rf "$dir" ;; \ + esac; \ + done; \ + fi # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default From 35d1515b6685e14d8803d4eaacb55e80b25f5f82 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 23:15:24 -0400 Subject: [PATCH 15/18] Auto-initialize ACP session in Nova channel for AgentCore routing When a message arrives and no ACP session exists for the session key, auto-initialize one before dispatching. This ensures messages route to the AgentCore backend instead of falling through to the embedded Pi agent, which would try to call the model directly. Co-Authored-By: Claude Opus 4.6 --- extensions/nova/src/monitor.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 13f969f6c7a29..1c11b70a79746 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -5,6 +5,7 @@ import { type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; +import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime"; import WebSocket from "ws"; import { getHyperionRuntime, hasHyperionRuntime } from "../../hyperion/src/globals.js"; import { setActiveNovaConnection } from "./connection.js"; @@ -284,6 +285,27 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise }, }); + // Auto-initialize ACP session if none exists, so messages route to + // AgentCore instead of falling through to the embedded Pi agent. + const acpSessionKey = ctxPayload.SessionKey ?? route.sessionKey; + if (acpSessionKey && msgCfg.acp?.enabled !== false && msgCfg.acp?.backend) { + const acpManager = getAcpSessionManager(); + const resolution = acpManager.resolveSession({ cfg: msgCfg, sessionKey: acpSessionKey }); + if (resolution.kind === "none") { + try { + await acpManager.initializeSession({ + cfg: msgCfg, + sessionKey: acpSessionKey, + agent: route.agentId ?? "main", + mode: "persistent", + }); + logger.info(`nova: auto-initialized ACP session for ${acpSessionKey}`); + } catch (err) { + logger.warn(`nova: ACP session auto-init failed, falling back to embedded agent: ${String(err)}`); + } + } + } + try { const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, From d6a5a8a53a2f1a4d7e229a06c0540a2acb14ec1e Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Thu, 19 Mar 2026 23:36:49 -0400 Subject: [PATCH 16/18] Revert extension stripping from Dockerfile Keep all extensions in the runtime image. The OC config's plugins.allow controls which extensions are loaded at runtime, so physical removal is unnecessary. This fixes startup failures when the config references extensions that were stripped (amazon-nova, memory-core). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 12 ------------ extensions/nova/src/monitor.ts | 4 +++- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0ce2f86837a2e..fa97f83323a26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,20 +96,8 @@ RUN pnpm ui:build # Prune dev dependencies and strip build-only metadata before copying # runtime assets into the final image. FROM build AS runtime-assets -ARG OPENCLAW_EXTENSIONS="" RUN CI=true pnpm prune --prod && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete -# When OPENCLAW_EXTENSIONS is set, remove all other extensions from the -# runtime image to avoid OOM from loading unused plugins at startup. -RUN if [ -n "$OPENCLAW_EXTENSIONS" ] && [ -d extensions ]; then \ - for dir in extensions/*/; do \ - name="$(basename "$dir")"; \ - case " $OPENCLAW_EXTENSIONS " in \ - *" $name "*) ;; \ - *) rm -rf "$dir" ;; \ - esac; \ - done; \ - fi # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts index 1c11b70a79746..dad5d5a374a2a 100644 --- a/extensions/nova/src/monitor.ts +++ b/extensions/nova/src/monitor.ts @@ -301,7 +301,9 @@ export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise }); logger.info(`nova: auto-initialized ACP session for ${acpSessionKey}`); } catch (err) { - logger.warn(`nova: ACP session auto-init failed, falling back to embedded agent: ${String(err)}`); + logger.warn( + `nova: ACP session auto-init failed, falling back to embedded agent: ${String(err)}`, + ); } } } From 6ddaa5876328344e52d3a13678dd4e0fa752597c Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Wed, 25 Mar 2026 13:17:15 -0400 Subject: [PATCH 17/18] Add AgentCore memory adapter, remove dead extensions, fix type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AgentCore-backed memory search adapter replacing OC's built-in SQLite + embedding pipeline, eliminating the need for a local embedding provider (OpenAI, Gemini, etc.) in the container. New: - AgentCoreMemoryManager implementing MemorySearchManager interface - memory-agentcore plugin (kind: "memory") with memory_search/memory_get tools - getAgentCoreConfig() singleton for cross-plugin config sharing Fixed: - runtime.ts SDK types (RetrieveMemoryRecordsCommand, BatchCreateMemoryRecordsCommand) - hyperion extension imports (openclaw/plugin-sdk → openclaw/plugin-sdk/core and /acpx) - runtime.test.ts mock casts (as unknown as ReturnType<...>) - tenant-config-loader.ts null safety and unknown type narrowing - memory-manager.ts inlines OC memory types (no src/ tree imports) - Updated cross-extension import inventory baseline Removed (migrated to extensions/hyperion/ and gateway): - extensions/nova/ — channel plugin migrated to gateway - src/hyperion/ — runtime layer migrated to extensions/hyperion/src/lib/ Co-Authored-By: Claude Opus 4.6 --- config/openclaw.json5 | 5 + extensions/agentcore/src/config.ts | 6 + extensions/agentcore/src/index.ts | 7 +- extensions/agentcore/src/memory-manager.ts | 149 +++++ extensions/agentcore/src/runtime.test.ts | 8 +- extensions/agentcore/src/runtime.ts | 29 +- extensions/agentcore/src/service.ts | 11 + extensions/agentcore/src/types.ts | 2 + extensions/hyperion/index.ts | 2 +- extensions/hyperion/src/lib/runtime.ts | 2 +- .../hyperion/src/lib/tenant-config-loader.ts | 11 +- extensions/hyperion/src/lib/types.ts | 2 +- extensions/hyperion/src/service.ts | 2 +- extensions/memory-agentcore/index.ts | 177 ++++++ .../openclaw.plugin.json | 4 +- extensions/memory-agentcore/package.json | 20 + extensions/nova/index.ts | 17 - extensions/nova/package.json | 32 - extensions/nova/src/channel.ts | 196 ------ extensions/nova/src/connection.ts | 11 - extensions/nova/src/credentials.test.ts | 162 ----- extensions/nova/src/credentials.ts | 30 - extensions/nova/src/inbound.test.ts | 142 ----- extensions/nova/src/inbound.ts | 35 -- extensions/nova/src/index.ts | 4 - extensions/nova/src/monitor.ts | 329 ---------- extensions/nova/src/onboarding.ts | 199 ------ extensions/nova/src/outbound.ts | 20 - extensions/nova/src/probe.test.ts | 67 -- extensions/nova/src/probe.ts | 42 -- extensions/nova/src/runtime.ts | 14 - extensions/nova/src/send.ts | 47 -- extensions/nova/src/types.ts | 47 -- src/hyperion/channel-identity-resolver.ts | 132 ---- src/hyperion/dynamodb-client.test.ts | 577 ------------------ src/hyperion/dynamodb-client.ts | 265 -------- src/hyperion/index.ts | 68 --- src/hyperion/pairing-store.test.ts | 227 ------- src/hyperion/pairing-store.ts | 162 ----- src/hyperion/runtime.ts | 90 --- src/hyperion/session-manager.test.ts | 177 ------ src/hyperion/session-manager.ts | 158 ----- src/hyperion/tenant-config-loader.test.ts | 249 -------- src/hyperion/tenant-config-loader.ts | 278 --------- src/hyperion/types.ts | 204 ------- src/hyperion/user-credential-store.ts | 186 ------ ...on-relative-outside-package-inventory.json | 35 +- 47 files changed, 447 insertions(+), 4192 deletions(-) create mode 100644 extensions/agentcore/src/memory-manager.ts create mode 100644 extensions/memory-agentcore/index.ts rename extensions/{nova => memory-agentcore}/openclaw.plugin.json (68%) create mode 100644 extensions/memory-agentcore/package.json delete mode 100644 extensions/nova/index.ts delete mode 100644 extensions/nova/package.json delete mode 100644 extensions/nova/src/channel.ts delete mode 100644 extensions/nova/src/connection.ts delete mode 100644 extensions/nova/src/credentials.test.ts delete mode 100644 extensions/nova/src/credentials.ts delete mode 100644 extensions/nova/src/inbound.test.ts delete mode 100644 extensions/nova/src/inbound.ts delete mode 100644 extensions/nova/src/index.ts delete mode 100644 extensions/nova/src/monitor.ts delete mode 100644 extensions/nova/src/onboarding.ts delete mode 100644 extensions/nova/src/outbound.ts delete mode 100644 extensions/nova/src/probe.test.ts delete mode 100644 extensions/nova/src/probe.ts delete mode 100644 extensions/nova/src/runtime.ts delete mode 100644 extensions/nova/src/send.ts delete mode 100644 extensions/nova/src/types.ts delete mode 100644 src/hyperion/channel-identity-resolver.ts delete mode 100644 src/hyperion/dynamodb-client.test.ts delete mode 100644 src/hyperion/dynamodb-client.ts delete mode 100644 src/hyperion/index.ts delete mode 100644 src/hyperion/pairing-store.test.ts delete mode 100644 src/hyperion/pairing-store.ts delete mode 100644 src/hyperion/runtime.ts delete mode 100644 src/hyperion/session-manager.test.ts delete mode 100644 src/hyperion/session-manager.ts delete mode 100644 src/hyperion/tenant-config-loader.test.ts delete mode 100644 src/hyperion/tenant-config-loader.ts delete mode 100644 src/hyperion/types.ts delete mode 100644 src/hyperion/user-credential-store.ts diff --git a/config/openclaw.json5 b/config/openclaw.json5 index bc65f5e9a9c94..6e67542b6765d 100644 --- a/config/openclaw.json5 +++ b/config/openclaw.json5 @@ -29,6 +29,11 @@ // Extensions loaded at startup (built into the container image via OPENCLAW_EXTENSIONS). plugins: { enabled: true, + // Use AgentCore Memory for memory_search instead of built-in SQLite + embeddings. + // This avoids needing a local embedding provider (OpenAI, Gemini, etc.) in the container. + slots: { + memory: "memory-agentcore", + }, }, // Logging — structured JSON for CloudWatch ingestion. diff --git a/extensions/agentcore/src/config.ts b/extensions/agentcore/src/config.ts index 9dfa4788425ec..e1957469ebd54 100644 --- a/extensions/agentcore/src/config.ts +++ b/extensions/agentcore/src/config.ts @@ -34,6 +34,7 @@ export async function loadAgentCoreConfig( runtimeArns: source.localOverride.runtimeArns ?? [], memoryNamespacePrefix: source.localOverride.memoryNamespacePrefix ?? DEFAULT_MEMORY_NAMESPACE_PREFIX, + memoryId: source.localOverride.memoryId, defaultModel: source.localOverride.defaultModel ?? DEFAULT_MODEL, endpoint: source.localOverride.endpoint, invokeTimeoutMs: source.localOverride.invokeTimeoutMs, @@ -59,11 +60,15 @@ export async function loadAgentCoreConfig( } let memoryNamespacePrefix = DEFAULT_MEMORY_NAMESPACE_PREFIX; + let memoryId: string | undefined; try { const parsed = JSON.parse(memoryConfigParam ?? "{}"); if (parsed && typeof parsed.memoryNamespacePrefix === "string") { memoryNamespacePrefix = parsed.memoryNamespacePrefix; } + if (parsed && typeof parsed.memoryId === "string" && parsed.memoryId) { + memoryId = parsed.memoryId; + } } catch { // Fall through to default } @@ -74,6 +79,7 @@ export async function loadAgentCoreConfig( region, runtimeArns, memoryNamespacePrefix, + memoryId, defaultModel, ...(source.endpointOverride ? { endpoint: source.endpointOverride } : {}), }; diff --git a/extensions/agentcore/src/index.ts b/extensions/agentcore/src/index.ts index d4a2fff1a8345..30cd4ce281cd8 100644 --- a/extensions/agentcore/src/index.ts +++ b/extensions/agentcore/src/index.ts @@ -1,4 +1,9 @@ export { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.js"; -export { createAgentCoreRuntimeService, type CreateAgentCoreServiceParams } from "./service.js"; +export { + createAgentCoreRuntimeService, + getAgentCoreConfig, + type CreateAgentCoreServiceParams, +} from "./service.js"; export { loadAgentCoreConfig, type AgentCoreConfigSource } from "./config.js"; +export { AgentCoreMemoryManager, type AgentCoreMemoryManagerParams } from "./memory-manager.js"; export type { AgentCoreRuntimeConfig, AgentCoreHandleState } from "./types.js"; diff --git a/extensions/agentcore/src/memory-manager.ts b/extensions/agentcore/src/memory-manager.ts new file mode 100644 index 0000000000000..37516a78e6130 --- /dev/null +++ b/extensions/agentcore/src/memory-manager.ts @@ -0,0 +1,149 @@ +import { + BedrockAgentCoreClient, + RetrieveMemoryRecordsCommand, +} from "@aws-sdk/client-bedrock-agentcore"; +import type { AgentCoreRuntimeConfig } from "./types.js"; + +// Inlined from OC's src/memory/types.ts to avoid importing the repo src/ tree +// (bundled extensions must only use openclaw/plugin-sdk/). +export type MemorySearchResult = { + path: string; + startLine: number; + endLine: number; + score: number; + snippet: string; + source: "memory"; +}; + +export type MemoryProviderStatus = { + backend: string; + provider: string; + model: string; + custom?: Record; +}; + +export type MemoryEmbeddingProbeResult = { ok: true } | { ok: false; error: string }; + +export interface MemorySearchManager { + search( + query: string, + opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, + ): Promise; + readFile(params: { + relPath: string; + from?: number; + lines?: number; + }): Promise<{ text: string; path: string }>; + status(): MemoryProviderStatus; + probeEmbeddingAvailability(): Promise; + probeVectorAvailability(): Promise; + close(): Promise; +} + +const DEFAULT_MAX_RESULTS = 10; +const DEFAULT_MIN_SCORE = 0.0; + +export type AgentCoreMemoryManagerParams = { + config: AgentCoreRuntimeConfig; + /** Tenant namespace (e.g. "tenant_{userId}:{agentId}"). */ + namespace: string; +}; + +/** + * MemorySearchManager backed by AgentCore Memory (RetrieveMemoryRecords). + * + * Replaces OC's built-in SQLite + embedding pipeline. AgentCore handles + * embeddings and vector search server-side — no local embedding provider needed. + */ +export class AgentCoreMemoryManager implements MemorySearchManager { + private readonly client: BedrockAgentCoreClient; + private readonly config: AgentCoreRuntimeConfig; + private readonly namespace: string; + + constructor(params: AgentCoreMemoryManagerParams) { + this.config = params.config; + this.namespace = params.namespace; + this.client = new BedrockAgentCoreClient({ + region: params.config.region, + ...(params.config.endpoint ? { endpoint: params.config.endpoint } : {}), + }); + } + + async search( + query: string, + opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, + ): Promise { + const maxResults = opts?.maxResults ?? DEFAULT_MAX_RESULTS; + const minScore = opts?.minScore ?? DEFAULT_MIN_SCORE; + + const resp = await this.client.send( + new RetrieveMemoryRecordsCommand({ + memoryId: this.config.memoryId, + namespace: this.namespace, + searchCriteria: { + searchQuery: query, + topK: maxResults, + }, + maxResults, + }), + ); + + const summaries = resp.memoryRecordSummaries; + if (!summaries?.length) { + return []; + } + + return summaries + .filter((_r, _i) => { + // MemoryRecordSummary does not expose a score field; include all results. + // Filtering by minScore is a no-op but kept for interface compatibility. + return true; + }) + .map((r, i) => ({ + path: `agentcore-memory://${this.namespace}/${r.memoryRecordId ?? `record-${i}`}`, + startLine: 0, + endLine: 0, + score: 1.0 - i * 0.01, // Synthetic score based on rank order + snippet: r.content?.text ?? "", + source: "memory" as const, + })); + } + + async readFile(_params: { + relPath: string; + from?: number; + lines?: number; + }): Promise<{ text: string; path: string }> { + // AgentCore Memory is not file-based. Return empty for memory_get calls. + return { text: "", path: _params.relPath }; + } + + status(): MemoryProviderStatus { + return { + backend: "builtin", + provider: "agentcore", + model: "agentcore-managed", + custom: { + memoryId: this.config.memoryId, + namespace: this.namespace, + region: this.config.region, + }, + }; + } + + async probeEmbeddingAvailability(): Promise { + // AgentCore manages embeddings server-side — always available if memoryId is set. + if (!this.config.memoryId) { + return { ok: false, error: "AgentCore memoryId not configured" }; + } + return { ok: true }; + } + + async probeVectorAvailability(): Promise { + return !!this.config.memoryId; + } + + async close(): Promise { + this.client.destroy(); + } +} diff --git a/extensions/agentcore/src/runtime.test.ts b/extensions/agentcore/src/runtime.test.ts index 2aa7e6d5184be..053d734801a1a 100644 --- a/extensions/agentcore/src/runtime.test.ts +++ b/extensions/agentcore/src/runtime.test.ts @@ -592,9 +592,9 @@ describe("AgentCoreRuntime", () => { }; vi.mocked(hasHyperionRuntime).mockReturnValue(true); - vi.mocked(getHyperionRuntime).mockReturnValue({ dbClient: mockDbClient } as ReturnType< - typeof getHyperionRuntime - >); + vi.mocked(getHyperionRuntime).mockReturnValue({ + dbClient: mockDbClient, + } as unknown as ReturnType); const runtime = createRuntime(); const handle = await runtime.ensureSession(makeEnsureInput()); @@ -776,7 +776,7 @@ describe("AgentCoreRuntime", () => { dbClient: { getTenantConfig: vi.fn().mockRejectedValue(new Error("DDB timeout")), }, - } as ReturnType); + } as unknown as ReturnType); const runtime = createRuntime(); const handle = await runtime.ensureSession(makeEnsureInput()); diff --git a/extensions/agentcore/src/runtime.ts b/extensions/agentcore/src/runtime.ts index 02bea74f7d8c3..c1a5663ed1895 100644 --- a/extensions/agentcore/src/runtime.ts +++ b/extensions/agentcore/src/runtime.ts @@ -4,7 +4,7 @@ import { InvokeAgentRuntimeCommand, StopRuntimeSessionCommand, RetrieveMemoryRecordsCommand, - StartMemoryExtractionJobCommand, + BatchCreateMemoryRecordsCommand, } from "@aws-sdk/client-bedrock-agentcore"; import type { AcpRuntime, @@ -339,15 +339,16 @@ export class AgentCoreRuntime implements AcpRuntime { try { const resp = await this.client.send( new RetrieveMemoryRecordsCommand({ + memoryId: this.config.memoryId, namespace, - query: { text: query }, + searchCriteria: { searchQuery: query, topK: 10 }, maxResults: 10, }), ); - if (!resp.records || resp.records.length === 0) return []; - return resp.records.map((r) => ({ + const summaries = resp.memoryRecordSummaries; + if (!summaries || summaries.length === 0) return []; + return summaries.map((r) => ({ content: r.content?.text ?? "", - score: r.score, })); } catch { // Non-fatal: agent runs without memory context on failure @@ -364,13 +365,21 @@ export class AgentCoreRuntime implements AcpRuntime { userMessage: string, agentResponse: string, ): Promise { + if (!this.config.memoryId) return; try { await this.client.send( - new StartMemoryExtractionJobCommand({ - namespace, - content: { - text: `User: ${userMessage}\nAssistant: ${agentResponse}`, - }, + new BatchCreateMemoryRecordsCommand({ + memoryId: this.config.memoryId, + records: [ + { + requestIdentifier: crypto.randomUUID(), + namespaces: [namespace], + content: { + text: `User: ${userMessage}\nAssistant: ${agentResponse}`, + }, + timestamp: new Date(), + }, + ], }), ); } catch { diff --git a/extensions/agentcore/src/service.ts b/extensions/agentcore/src/service.ts index 9bbfd2f7db107..a1349ca21f949 100644 --- a/extensions/agentcore/src/service.ts +++ b/extensions/agentcore/src/service.ts @@ -6,6 +6,7 @@ import type { import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk/acpx"; import { loadAgentCoreConfig, type AgentCoreConfigSource } from "./config.js"; import { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.js"; +import type { AgentCoreRuntimeConfig } from "./types.js"; type AgentCoreRuntimeLike = AcpRuntime & { isHealthy(): boolean; @@ -13,6 +14,14 @@ type AgentCoreRuntimeLike = AcpRuntime & { doctor(): Promise<{ ok: boolean; message: string }>; }; +// Singleton: the loaded config is stored here so the memory plugin can access it. +let _loadedConfig: AgentCoreRuntimeConfig | null = null; + +/** Returns the AgentCore config loaded at startup, or null if not yet started. */ +export function getAgentCoreConfig(): AgentCoreRuntimeConfig | null { + return _loadedConfig; +} + export type CreateAgentCoreServiceParams = { configSource: AgentCoreConfigSource; }; @@ -52,6 +61,7 @@ export function createAgentCoreRuntimeService( } runtime = new AgentCoreRuntime(config); + _loadedConfig = config; registerAcpRuntimeBackend({ id: AGENTCORE_BACKEND_ID, @@ -85,6 +95,7 @@ export function createAgentCoreRuntimeService( async stop(_ctx: OpenClawPluginServiceContext): Promise { unregisterAcpRuntimeBackend(AGENTCORE_BACKEND_ID); runtime = null; + _loadedConfig = null; }, }; } diff --git a/extensions/agentcore/src/types.ts b/extensions/agentcore/src/types.ts index ecbe8808d6f0c..5eeb1ec27dcba 100644 --- a/extensions/agentcore/src/types.ts +++ b/extensions/agentcore/src/types.ts @@ -9,6 +9,8 @@ export type AgentCoreRuntimeConfig = { runtimeArns: string[]; /** Prefix for per-tenant memory namespacing (e.g. "tenant_"). */ memoryNamespacePrefix: string; + /** AgentCore Memory resource ID (from CDK CfnMemory). */ + memoryId?: string; /** Default Bedrock model ID (e.g. "anthropic.claude-sonnet-4-20250514"). */ defaultModel: string; /** AgentCore endpoint override (for local testing). */ diff --git a/extensions/hyperion/index.ts b/extensions/hyperion/index.ts index cf2021c920b73..8b267d7caea2e 100644 --- a/extensions/hyperion/index.ts +++ b/extensions/hyperion/index.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createHyperionPluginService, type HyperionPluginConfig } from "./src/service.js"; const plugin = { diff --git a/extensions/hyperion/src/lib/runtime.ts b/extensions/hyperion/src/lib/runtime.ts index f6f844f528aa8..68f270c4e223b 100644 --- a/extensions/hyperion/src/lib/runtime.ts +++ b/extensions/hyperion/src/lib/runtime.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { ChannelIdentityResolver } from "./channel-identity-resolver.js"; import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; import { HyperionPairingStore } from "./pairing-store.js"; diff --git a/extensions/hyperion/src/lib/tenant-config-loader.ts b/extensions/hyperion/src/lib/tenant-config-loader.ts index 54484831f3dd6..66fc679939e19 100644 --- a/extensions/hyperion/src/lib/tenant-config-loader.ts +++ b/extensions/hyperion/src/lib/tenant-config-loader.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; // ChannelsConfig is the channels section of OpenClawConfig. // Defined inline to avoid importing from OC internals. @@ -142,7 +142,10 @@ export class TenantConfigLoader { }; } - channels[platform]!.accounts[accountId] = this.buildAccountConfig(link); + const platformConfig = channels[platform]; + if (platformConfig?.accounts) { + platformConfig.accounts[accountId] = this.buildAccountConfig(link); + } } return channels; @@ -256,7 +259,9 @@ export class TenantConfigLoader { for (const [platform, token] of Object.entries(credentials.channel_tokens)) { const platformConfig = channels[platform]; if (platformConfig?.accounts) { - for (const account of Object.values(platformConfig.accounts)) { + for (const account of Object.values(platformConfig.accounts) as Array< + Record + >) { account.botToken = token; } } diff --git a/extensions/hyperion/src/lib/types.ts b/extensions/hyperion/src/lib/types.ts index 04dd0358603e2..015bcb87b5cd6 100644 --- a/extensions/hyperion/src/lib/types.ts +++ b/extensions/hyperion/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; /** * Supported external channel platforms for Hyperion. diff --git a/extensions/hyperion/src/service.ts b/extensions/hyperion/src/service.ts index dce89133728bd..0f4b893d353c1 100644 --- a/extensions/hyperion/src/service.ts +++ b/extensions/hyperion/src/service.ts @@ -1,7 +1,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { KMSClient } from "@aws-sdk/client-kms"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; -import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/acpx"; import { buildDynamoConfig, resolveStage } from "./env.js"; import { clearHyperionRuntime, setHyperionRuntime } from "./globals.js"; import { createHyperionRuntime } from "./lib/index.js"; diff --git a/extensions/memory-agentcore/index.ts b/extensions/memory-agentcore/index.ts new file mode 100644 index 0000000000000..e03d455f58737 --- /dev/null +++ b/extensions/memory-agentcore/index.ts @@ -0,0 +1,177 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { getAgentCoreConfig, AgentCoreMemoryManager } from "../agentcore/src/index.js"; +import { extractTenantId, extractAgentId } from "../hyperion/src/lib/index.js"; + +// Cache managers by namespace to avoid creating a new client per tool call. +const managerCache = new Map(); + +function getOrCreateManager(namespace: string): AgentCoreMemoryManager | null { + const config = getAgentCoreConfig(); + if (!config?.memoryId) { + return null; + } + + const cached = managerCache.get(namespace); + if (cached) { + return cached; + } + + const manager = new AgentCoreMemoryManager({ config, namespace }); + managerCache.set(namespace, manager); + return manager; +} + +function resolveNamespace(sessionKey?: string): string | null { + if (!sessionKey) return null; + const config = getAgentCoreConfig(); + if (!config) return null; + const tenantId = extractTenantId(sessionKey); + if (!tenantId) return null; + const agentId = extractAgentId(sessionKey); + return `${config.memoryNamespacePrefix}${tenantId}:${agentId}`; +} + +export default definePluginEntry({ + id: "memory-agentcore", + name: "Memory (AgentCore)", + description: + "Memory search backed by AWS Bedrock AgentCore — no local embedding provider needed.", + kind: "memory", + register(api) { + const memorySearchTool = api.runtime.tools.createMemorySearchTool; + const memoryGetTool = api.runtime.tools.createMemoryGetTool; + + // Override the memory search tool factory: the tool creation uses OC's + // built-in createMemorySearchTool for the tool shape, but we replace + // the underlying MemorySearchManager with our AgentCore adapter. + // + // However, OC's createMemorySearchTool internally calls getMemorySearchManager() + // which routes through the backend-config resolution. To fully bypass that, + // we register custom tools that call AgentCoreMemoryManager directly. + api.registerTool( + (ctx) => { + const namespace = resolveNamespace(ctx.sessionKey); + if (!namespace) { + // Fall back to OC's built-in tools if we can't resolve namespace + const search = memorySearchTool({ config: ctx.config, agentSessionKey: ctx.sessionKey }); + const get = memoryGetTool({ config: ctx.config, agentSessionKey: ctx.sessionKey }); + if (!search || !get) return null; + return [search, get]; + } + + const manager = getOrCreateManager(namespace); + if (!manager) { + // No memoryId configured — fall back to built-in + const search = memorySearchTool({ config: ctx.config, agentSessionKey: ctx.sessionKey }); + const get = memoryGetTool({ config: ctx.config, agentSessionKey: ctx.sessionKey }); + if (!search || !get) return null; + return [search, get]; + } + + const searchTool = { + label: "Memory Search", + name: "memory_search", + description: + "Mandatory recall step: semantically search agent memory before answering questions about prior work, decisions, dates, people, preferences, or todos. Powered by AgentCore Memory (server-side embeddings).", + parameters: { + type: "object" as const, + properties: { + query: { type: "string" as const }, + maxResults: { type: "number" as const }, + minScore: { type: "number" as const }, + }, + required: ["query"] as const, + }, + async execute(_toolCallId: string, params: Record) { + const query = + typeof params.query === "string" ? params.query : String(params.query ?? ""); + const maxResults = + typeof params.maxResults === "number" ? params.maxResults : undefined; + const minScore = typeof params.minScore === "number" ? params.minScore : undefined; + try { + const results = await manager.search(query, { + maxResults, + minScore, + sessionKey: ctx.sessionKey, + }); + const status = manager.status(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + results, + provider: status.provider, + model: status.model, + }), + }, + ], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + results: [], + disabled: true, + unavailable: true, + error: message, + warning: "Memory search is unavailable due to an AgentCore error.", + action: "Check AgentCore Memory configuration and retry memory_search.", + }), + }, + ], + }; + } + }, + }; + + const getTool = { + label: "Memory Get", + name: "memory_get", + description: + "Read a specific memory record by path. Use after memory_search to retrieve full content.", + parameters: { + type: "object" as const, + properties: { + path: { type: "string" as const }, + from: { type: "number" as const }, + lines: { type: "number" as const }, + }, + required: ["path"] as const, + }, + async execute(_toolCallId: string, params: Record) { + const relPath = + typeof params.path === "string" ? params.path : String(params.path ?? ""); + try { + const result = await manager.readFile({ relPath }); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + path: relPath, + text: "", + disabled: true, + error: message, + }), + }, + ], + }; + } + }, + }; + + return [searchTool, getTool] as any; + }, + { names: ["memory_search", "memory_get"] }, + ); + }, +}); diff --git a/extensions/nova/openclaw.plugin.json b/extensions/memory-agentcore/openclaw.plugin.json similarity index 68% rename from extensions/nova/openclaw.plugin.json rename to extensions/memory-agentcore/openclaw.plugin.json index fe7c507367f31..a724e0e670d6c 100644 --- a/extensions/nova/openclaw.plugin.json +++ b/extensions/memory-agentcore/openclaw.plugin.json @@ -1,6 +1,6 @@ { - "id": "nova", - "channels": ["nova"], + "id": "memory-agentcore", + "kind": "memory", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/memory-agentcore/package.json b/extensions/memory-agentcore/package.json new file mode 100644 index 0000000000000..4303ebabcdc8d --- /dev/null +++ b/extensions/memory-agentcore/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openclaw/memory-agentcore", + "version": "2026.3.25", + "private": true, + "description": "OpenClaw memory search plugin backed by AWS Bedrock AgentCore Memory", + "type": "module", + "peerDependencies": { + "openclaw": ">=2026.3.11" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/nova/index.ts b/extensions/nova/index.ts deleted file mode 100644 index 32adb22051ae5..0000000000000 --- a/extensions/nova/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; -import { novaPlugin } from "./src/channel.js"; -import { setNovaRuntime } from "./src/runtime.js"; - -const plugin = { - id: "nova", - name: "Nova", - description: "Nova channel plugin (nova.amazon.com)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - setNovaRuntime(api.runtime); - api.registerChannel({ plugin: novaPlugin }); - }, -}; - -export default plugin; diff --git a/extensions/nova/package.json b/extensions/nova/package.json deleted file mode 100644 index ea70a0d4433da..0000000000000 --- a/extensions/nova/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@openclaw/nova", - "version": "2026.2.1", - "description": "OpenClaw Nova channel plugin (nova.amazon.com)", - "type": "module", - "dependencies": { - "ws": "^8.18.0" - }, - "devDependencies": { - "@types/ws": "^8.5.14", - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "channel": { - "id": "nova", - "label": "Nova", - "selectionLabel": "Nova (WebSocket)", - "docsPath": "/channels/nova", - "docsLabel": "nova", - "blurb": "nova.amazon.com via WebSocket.", - "order": 80 - }, - "install": { - "npmSpec": "@openclaw/nova", - "localPath": "extensions/nova", - "defaultChoice": "local" - } - } -} diff --git a/extensions/nova/src/channel.ts b/extensions/nova/src/channel.ts deleted file mode 100644 index a4647f46b7b3c..0000000000000 --- a/extensions/nova/src/channel.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; -import { resolveNovaCredentials } from "./credentials.js"; -import { novaOnboardingAdapter } from "./onboarding.js"; -import { novaOutbound } from "./outbound.js"; -import { probeNova } from "./probe.js"; -import { sendNovaMessage } from "./send.js"; -import type { NovaConfig } from "./types.js"; - -type ResolvedNovaAccount = { - accountId: string; - enabled: boolean; - configured: boolean; -}; - -const meta = { - id: "nova", - label: "Nova", - selectionLabel: "Nova (WebSocket)", - docsPath: "/channels/nova", - docsLabel: "nova", - blurb: "nova.amazon.com via WebSocket.", - order: 80, -} as const; - -export const novaPlugin: ChannelPlugin = { - id: "nova", - meta: { ...meta }, - onboarding: novaOnboardingAdapter, - pairing: { - idLabel: "novaUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(nova|user):/i, ""), - notifyApproval: async ({ cfg, id }) => { - sendNovaMessage({ - cfg, - to: id, - text: "Your pairing request has been approved.", - done: true, - }); - }, - }, - capabilities: { - chatTypes: ["direct"], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 50, idleMs: 200 }, - }, - reload: { configPrefixes: ["channels.nova"] }, - config: { - listAccountIds: () => [DEFAULT_ACCOUNT_ID], - resolveAccount: (cfg) => ({ - accountId: DEFAULT_ACCOUNT_ID, - enabled: cfg.channels?.nova?.enabled !== false, - configured: Boolean(resolveNovaCredentials(cfg.channels?.nova as NovaConfig | undefined)), - }), - defaultAccountId: () => DEFAULT_ACCOUNT_ID, - setAccountEnabled: ({ cfg, enabled }) => ({ - ...cfg, - channels: { - ...cfg.channels, - nova: { - ...cfg.channels?.nova, - enabled, - }, - }, - }), - deleteAccount: ({ cfg }) => { - const next = { ...cfg } as OpenClawConfig; - const nextChannels = { ...cfg.channels }; - delete nextChannels.nova; - if (Object.keys(nextChannels).length > 0) { - next.channels = nextChannels; - } else { - delete next.channels; - } - return next; - }, - isConfigured: (_account, cfg) => - Boolean(resolveNovaCredentials(cfg.channels?.nova as NovaConfig | undefined)), - describeAccount: (account) => ({ - accountId: account.accountId, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg }) => cfg.channels?.nova?.allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.toLowerCase()), - }, - security: { - collectWarnings: ({ cfg }) => { - const novaCfg = cfg.channels?.nova as NovaConfig | undefined; - const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; - if (dmPolicy === "open") { - return [ - `- Nova: dmPolicy="open" allows any Nova user to send messages. Set channels.nova.dmPolicy="allowlist" + channels.nova.allowFrom to restrict senders.`, - ]; - } - return []; - }, - }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - nova: { - ...cfg.channels?.nova, - enabled: true, - }, - }, - }), - }, - messaging: { - normalizeTarget: (raw) => { - const trimmed = raw.trim().replace(/^nova:/i, ""); - return trimmed || null; - }, - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^nova:/i.test(trimmed)) { - return true; - } - // Nova user IDs are opaque strings; accept anything non-empty - return trimmed.length > 0; - }, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const entry of cfg.channels?.nova?.allowFrom ?? []) { - const trimmed = String(entry).trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - return Array.from(ids) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id }) as const); - }, - listGroups: async () => [], - }, - outbound: novaOutbound, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ cfg }) => probeNova(cfg.channels?.nova as NovaConfig | undefined), - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - enabled: account.enabled, - configured: account.configured, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - }), - }, - gateway: { - startAccount: async (ctx) => { - const { monitorNovaProvider } = await import("./monitor.js"); - ctx.log?.info("starting Nova WebSocket provider"); - return monitorNovaProvider({ - cfg: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - }); - }, - }, -}; diff --git a/extensions/nova/src/connection.ts b/extensions/nova/src/connection.ts deleted file mode 100644 index 169e4baf03932..0000000000000 --- a/extensions/nova/src/connection.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type WebSocket from "ws"; - -let activeConnection: WebSocket | null = null; - -export function setActiveNovaConnection(ws: WebSocket | null): void { - activeConnection = ws; -} - -export function getActiveNovaConnection(): WebSocket | null { - return activeConnection; -} diff --git a/extensions/nova/src/credentials.test.ts b/extensions/nova/src/credentials.test.ts deleted file mode 100644 index 070fb19987034..0000000000000 --- a/extensions/nova/src/credentials.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveNovaCredentials } from "./credentials.js"; -import type { NovaConfig } from "./types.js"; - -describe("resolveNovaCredentials", () => { - const savedEnv: Record = {}; - - beforeEach(() => { - savedEnv.NOVA_BASE_URL = process.env.NOVA_BASE_URL; - savedEnv.NOVA_API_KEY = process.env.NOVA_API_KEY; - savedEnv.NOVA_USER_ID = process.env.NOVA_USER_ID; - savedEnv.NOVA_DEVICE_ID = process.env.NOVA_DEVICE_ID; - delete process.env.NOVA_BASE_URL; - delete process.env.NOVA_API_KEY; - delete process.env.NOVA_USER_ID; - delete process.env.NOVA_DEVICE_ID; - }); - - afterEach(() => { - for (const [key, val] of Object.entries(savedEnv)) { - if (val === undefined) { - delete process.env[key]; - } else { - process.env[key] = val; - } - } - }); - - it("resolves credentials from config", () => { - const cfg: NovaConfig = { - baseUrl: "wss://custom.example.com", - apiKey: "key-123", - userId: "user-001", - }; - const result = resolveNovaCredentials(cfg); - expect(result).toEqual( - expect.objectContaining({ - baseUrl: "wss://custom.example.com", - apiKey: "key-123", - userId: "user-001", - }), - ); - expect(result?.deviceId).toBeDefined(); - }); - - it("falls back to env vars when config is empty", () => { - process.env.NOVA_BASE_URL = "wss://env.example.com"; - process.env.NOVA_API_KEY = "env-key"; - process.env.NOVA_USER_ID = "env-user"; - expect(resolveNovaCredentials({})).toEqual( - expect.objectContaining({ - baseUrl: "wss://env.example.com", - apiKey: "env-key", - userId: "env-user", - }), - ); - }); - - it("prefers config over env vars", () => { - process.env.NOVA_BASE_URL = "wss://env.example.com"; - process.env.NOVA_API_KEY = "env-key"; - process.env.NOVA_USER_ID = "env-user"; - const cfg: NovaConfig = { - baseUrl: "wss://config.example.com", - apiKey: "cfg-key", - userId: "cfg-user", - }; - expect(resolveNovaCredentials(cfg)).toEqual( - expect.objectContaining({ - baseUrl: "wss://config.example.com", - apiKey: "cfg-key", - userId: "cfg-user", - }), - ); - }); - - it("uses default baseUrl when not specified", () => { - const cfg: NovaConfig = { - apiKey: "key", - userId: "user", - }; - const result = resolveNovaCredentials(cfg); - expect(result).toBeDefined(); - expect(result?.baseUrl).toBe("wss://ws.nova-claw.agi.amazon.dev"); - expect(result?.apiKey).toBe("key"); - expect(result?.userId).toBe("user"); - }); - - it("returns undefined when apiKey is missing", () => { - const cfg: NovaConfig = { - userId: "user", - }; - expect(resolveNovaCredentials(cfg)).toBeUndefined(); - }); - - it("returns undefined when userId is missing", () => { - const cfg: NovaConfig = { - apiKey: "key", - }; - expect(resolveNovaCredentials(cfg)).toBeUndefined(); - }); - - it("returns undefined for undefined config", () => { - expect(resolveNovaCredentials(undefined)).toBeUndefined(); - }); - - it("trims whitespace from values", () => { - const cfg: NovaConfig = { - baseUrl: " wss://example.com ", - apiKey: " key ", - userId: " user ", - }; - expect(resolveNovaCredentials(cfg)).toEqual( - expect.objectContaining({ - baseUrl: "wss://example.com", - apiKey: "key", - userId: "user", - }), - ); - }); - - it("uses default baseUrl for whitespace-only baseUrl", () => { - const cfg: NovaConfig = { - baseUrl: " ", - apiKey: "key", - userId: "user", - }; - const result = resolveNovaCredentials(cfg); - expect(result?.baseUrl).toBe("wss://ws.nova-claw.agi.amazon.dev"); - }); - - it("uses deviceId from config when provided", () => { - const cfg: NovaConfig = { - apiKey: "key", - userId: "user", - deviceId: "my-device-42", - }; - const result = resolveNovaCredentials(cfg); - expect(result?.deviceId).toBe("my-device-42"); - }); - - it("uses deviceId from env var when config is empty", () => { - process.env.NOVA_DEVICE_ID = "env-device-99"; - const cfg: NovaConfig = { - apiKey: "key", - userId: "user", - }; - const result = resolveNovaCredentials(cfg); - expect(result?.deviceId).toBe("env-device-99"); - }); - - it("generates a stable deviceId across calls when not configured", () => { - const cfg: NovaConfig = { - apiKey: "key", - userId: "user", - }; - const result1 = resolveNovaCredentials(cfg); - const result2 = resolveNovaCredentials(cfg); - expect(result1?.deviceId).toBeDefined(); - expect(result1?.deviceId).toBe(result2?.deviceId); - }); -}); diff --git a/extensions/nova/src/credentials.ts b/extensions/nova/src/credentials.ts deleted file mode 100644 index ae7db03bf76b1..0000000000000 --- a/extensions/nova/src/credentials.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NovaConfig, NovaCredentials } from "./types.js"; - -const DEFAULT_BASE_URL = "wss://ws.nova-claw.agi.amazon.dev"; - -/** - * Stable deviceId generated once per process lifetime. - * Reused across reconnects so the server can correlate sessions to the same device. - */ -let cachedDeviceId: string | undefined; - -/** - * Resolve Nova credentials from config with env var fallbacks. - * Returns `undefined` when any required field is missing. - */ -export function resolveNovaCredentials(cfg?: NovaConfig): NovaCredentials | undefined { - const baseUrl = cfg?.baseUrl?.trim() || process.env.NOVA_BASE_URL?.trim() || DEFAULT_BASE_URL; - const apiKey = cfg?.apiKey?.trim() || process.env.NOVA_API_KEY?.trim(); - const userId = cfg?.userId?.trim() || process.env.NOVA_USER_ID?.trim(); - - if (!apiKey || !userId) { - return undefined; - } - - const deviceId = - cfg?.deviceId?.trim() || - process.env.NOVA_DEVICE_ID?.trim() || - (cachedDeviceId ??= crypto.randomUUID()); - - return { baseUrl, apiKey, userId, deviceId }; -} diff --git a/extensions/nova/src/inbound.test.ts b/extensions/nova/src/inbound.test.ts deleted file mode 100644 index 47d721d21d52c..0000000000000 --- a/extensions/nova/src/inbound.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseNovaInboundMessage } from "./inbound.js"; - -describe("parseNovaInboundMessage", () => { - it("parses a valid message", () => { - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: "Hello, world!", - messageId: "msg-001", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toEqual({ - action: "message", - userId: "user-42", - text: "Hello, world!", - messageId: "msg-001", - timestamp: 1707500000000, - }); - }); - - it("returns null for invalid JSON", () => { - expect(parseNovaInboundMessage("not json")).toBeNull(); - }); - - it("returns null for non-object JSON", () => { - expect(parseNovaInboundMessage('"hello"')).toBeNull(); - expect(parseNovaInboundMessage("42")).toBeNull(); - expect(parseNovaInboundMessage("null")).toBeNull(); - }); - - it("returns null when action is not 'message'", () => { - const raw = JSON.stringify({ - action: "pong", - userId: "user-42", - text: "Hello", - messageId: "msg-001", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toBeNull(); - }); - - it("returns null when userId is missing", () => { - const raw = JSON.stringify({ - action: "message", - text: "Hello", - messageId: "msg-001", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toBeNull(); - }); - - it("returns null when userId is empty", () => { - const raw = JSON.stringify({ - action: "message", - userId: " ", - text: "Hello", - messageId: "msg-001", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toBeNull(); - }); - - it("returns null when messageId is missing", () => { - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: "Hello", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toBeNull(); - }); - - it("returns null when messageId is empty", () => { - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: "Hello", - messageId: "", - timestamp: 1707500000000, - }); - expect(parseNovaInboundMessage(raw)).toBeNull(); - }); - - it("accepts empty text", () => { - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: "", - messageId: "msg-001", - timestamp: 1707500000000, - }); - const result = parseNovaInboundMessage(raw); - expect(result).not.toBeNull(); - expect(result?.text).toBe(""); - }); - - it("defaults timestamp to Date.now() when missing", () => { - const before = Date.now(); - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: "Hello", - messageId: "msg-001", - }); - const result = parseNovaInboundMessage(raw); - const after = Date.now(); - expect(result).not.toBeNull(); - expect(result!.timestamp).toBeGreaterThanOrEqual(before); - expect(result!.timestamp).toBeLessThanOrEqual(after); - }); - - it("handles non-string text gracefully", () => { - const raw = JSON.stringify({ - action: "message", - userId: "user-42", - text: 123, - messageId: "msg-001", - timestamp: 1707500000000, - }); - const result = parseNovaInboundMessage(raw); - expect(result).not.toBeNull(); - expect(result?.text).toBe(""); - }); - - it("trims userId and messageId", () => { - const raw = JSON.stringify({ - action: "message", - userId: " user-42 ", - text: "Hello", - messageId: " msg-001 ", - timestamp: 1707500000000, - }); - const result = parseNovaInboundMessage(raw); - expect(result?.userId).toBe("user-42"); - expect(result?.messageId).toBe("msg-001"); - }); - - it("returns null for array input", () => { - expect(parseNovaInboundMessage("[]")).toBeNull(); - }); -}); diff --git a/extensions/nova/src/inbound.ts b/extensions/nova/src/inbound.ts deleted file mode 100644 index 65e219d2324c6..0000000000000 --- a/extensions/nova/src/inbound.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NovaInboundMessage } from "./types.js"; - -/** - * Parse an incoming WebSocket message from the API GW Lambda. - * Returns a typed message on success, or `null` for malformed/unrecognized frames. - */ -export function parseNovaInboundMessage(raw: string): NovaInboundMessage | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== "object") { - return null; - } - - const obj = parsed as Record; - - if (obj.action !== "message") { - return null; - } - - const userId = typeof obj.userId === "string" ? obj.userId.trim() : ""; - const text = typeof obj.text === "string" ? obj.text : ""; - const messageId = typeof obj.messageId === "string" ? obj.messageId.trim() : ""; - const timestamp = typeof obj.timestamp === "number" ? obj.timestamp : Date.now(); - - if (!userId || !messageId) { - return null; - } - - return { action: "message", userId, text, messageId, timestamp }; -} diff --git a/extensions/nova/src/index.ts b/extensions/nova/src/index.ts deleted file mode 100644 index a9b54fe0fa034..0000000000000 --- a/extensions/nova/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { monitorNovaProvider } from "./monitor.js"; -export { probeNova } from "./probe.js"; -export { sendNovaMessage } from "./send.js"; -export { type NovaCredentials, resolveNovaCredentials } from "./credentials.js"; diff --git a/extensions/nova/src/monitor.ts b/extensions/nova/src/monitor.ts deleted file mode 100644 index dad5d5a374a2a..0000000000000 --- a/extensions/nova/src/monitor.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { format } from "node:util"; -import { - createReplyPrefixContext, - DEFAULT_ACCOUNT_ID, - type OpenClawConfig, - type RuntimeEnv, -} from "openclaw/plugin-sdk"; -import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime"; -import WebSocket from "ws"; -import { getHyperionRuntime, hasHyperionRuntime } from "../../hyperion/src/globals.js"; -import { setActiveNovaConnection } from "./connection.js"; -import { resolveNovaCredentials } from "./credentials.js"; -import { parseNovaInboundMessage } from "./inbound.js"; -import { getNovaRuntime } from "./runtime.js"; -import { sendNovaMessage } from "./send.js"; -import type { NovaConfig } from "./types.js"; - -export type MonitorNovaOpts = { - cfg: OpenClawConfig; - runtime?: RuntimeEnv; - abortSignal?: AbortSignal; -}; - -const DEFAULT_RECONNECT_BASE_MS = 1000; -const MAX_RECONNECT_MS = 60_000; -const DEFAULT_HEARTBEAT_MS = 30_000; - -/** - * Persistent WebSocket client that connects to the Nova backend, - * receives inbound messages, dispatches them through the agent pipeline, - * and reconnects with exponential backoff on failure. - */ -export async function monitorNovaProvider(opts: MonitorNovaOpts): Promise { - const core = getNovaRuntime(); - const cfg = opts.cfg; - const novaCfg = cfg.channels?.nova as NovaConfig | undefined; - - if (novaCfg?.enabled === false) { - return; - } - - const creds = resolveNovaCredentials(novaCfg); - if (!creds) { - throw new Error("Nova credentials not configured (apiKey, userId)"); - } - - const logger = core.logging.getChildLogger({ module: "nova-monitor" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => logger.info(formatRuntimeMessage(...args)), - error: (...args) => logger.error(formatRuntimeMessage(...args)), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; - - const reconnectBaseMs = novaCfg?.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_MS; - const heartbeatIntervalMs = novaCfg?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS; - const _textLimit = core.channel.text.resolveTextChunkLimit(cfg, "nova"); - - let attempt = 0; - - await new Promise((resolveMonitor) => { - if (opts.abortSignal?.aborted) { - resolveMonitor(); - return; - } - - const onAbort = () => { - logger.info("nova: abort signal received, closing connection"); - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - clearHeartbeat(); - const ws = activeWs; - activeWs = null; - setActiveNovaConnection(null); - if (ws && ws.readyState !== WebSocket.CLOSED) { - ws.close(1000, "shutdown"); - } - resolveMonitor(); - }; - - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - - let activeWs: WebSocket | null = null; - let heartbeatTimer: ReturnType | null = null; - let reconnectTimer: ReturnType | null = null; - - function clearHeartbeat() { - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - } - - function startHeartbeat(ws: WebSocket) { - clearHeartbeat(); - heartbeatTimer = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ action: "ping", timestamp: Date.now() })); - } - }, heartbeatIntervalMs); - } - - function scheduleReconnect() { - if (opts.abortSignal?.aborted) { - resolveMonitor(); - return; - } - attempt++; - const jitter = Math.random() * 0.3 + 0.85; // 0.85..1.15 - const delay = Math.min(reconnectBaseMs * 2 ** attempt * jitter, MAX_RECONNECT_MS); - logger.info(`nova: reconnecting in ${Math.round(delay)}ms (attempt ${attempt})`); - reconnectTimer = setTimeout(connect, delay); - } - - function connect() { - if (opts.abortSignal?.aborted) { - resolveMonitor(); - return; - } - - const url = `${creds.baseUrl}?userId=${encodeURIComponent(creds.userId)}&deviceId=${encodeURIComponent(creds.deviceId)}`; - logger.info(`nova: connecting to ${creds.baseUrl}`); - - const ws = new WebSocket(url, { - headers: { Authorization: `Bearer ${creds.apiKey}` }, - }); - activeWs = ws; - - ws.on("open", () => { - logger.info("nova: WebSocket connected"); - setActiveNovaConnection(ws); - attempt = 0; - startHeartbeat(ws); - }); - - ws.on("message", (data: WebSocket.RawData) => { - const raw = typeof data === "string" ? data : Buffer.from(data as Buffer).toString("utf8"); - handleInboundMessage(raw, cfg, runtime).catch((err) => { - runtime.error?.(`nova: dispatch error: ${String(err)}`); - }); - }); - - ws.on("close", (code, reason) => { - logger.info(`nova: WebSocket closed (code=${code}, reason=${reason.toString("utf8")})`); - clearHeartbeat(); - setActiveNovaConnection(null); - activeWs = null; - scheduleReconnect(); - }); - - ws.on("error", (err) => { - logger.error(`nova: WebSocket error: ${String(err)}`); - // 'close' event will follow; reconnect handled there - }); - } - - async function handleInboundMessage( - raw: string, - gatewayCfg: OpenClawConfig, - msgRuntime: RuntimeEnv, - ): Promise { - const msg = parseNovaInboundMessage(raw); - if (!msg) { - // Ignore non-message frames (pong, ack, etc.) - return; - } - - // In multi-tenant mode (Hyperion runtime available), load per-tenant config. - // The tenant user_id is the msg.userId from the authenticated WebSocket. - // HWS has already authenticated the user, so no allowlist check needed. - let msgCfg: OpenClawConfig; - if (hasHyperionRuntime()) { - try { - const hyperion = getHyperionRuntime(); - msgCfg = await hyperion.configLoader.loadTenantConfig(msg.userId); - } catch (err) { - logger.error(`nova: failed to load tenant config for ${msg.userId}: ${String(err)}`); - return; - } - } else { - // Single-tenant fallback: use the static gateway config with allowlist check. - msgCfg = gatewayCfg; - - const dmPolicy = novaCfg?.dmPolicy ?? "allowlist"; - const allowFrom = (novaCfg?.allowFrom ?? []).map((entry) => - String(entry).trim().toLowerCase(), - ); - - if (dmPolicy === "allowlist" && !allowFrom.includes("*")) { - if (allowFrom.length === 0) { - logger.info(`nova: message from ${msg.userId} dropped (allowlist is empty)`); - return; - } - const senderId = msg.userId.trim().toLowerCase(); - if (!allowFrom.includes(senderId)) { - logger.info(`nova: message from ${msg.userId} dropped (not in allowlist)`); - return; - } - } - } - - const novaFrom = `nova:${msg.userId}`; - const novaTo = `nova:${creds.userId}`; - - const route = core.channel.routing.resolveAgentRoute({ - cfg: msgCfg, - channel: "nova", - accountId: DEFAULT_ACCOUNT_ID, - peer: { kind: "user", id: msg.userId }, - }); - - const storePath = core.channel.session.resolveStorePath(msgCfg.session?.store, { - agentId: route.agentId, - }); - - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: msg.text, - RawBody: msg.text, - CommandBody: msg.text, - From: novaFrom, - To: novaTo, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: "direct" as const, - ConversationLabel: novaFrom, - SenderName: msg.userId, - SenderId: msg.userId, - Provider: "nova" as const, - Surface: "nova" as const, - MessageSid: msg.messageId, - Timestamp: msg.timestamp, - WasMentioned: true, // DMs are always "mentioned" - CommandAuthorized: true, - OriginatingChannel: "nova" as const, - OriginatingTo: novaTo, - }); - - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - logger.debug(`nova: failed updating session meta: ${String(err)}`); - }, - }); - - logger.info(`nova inbound: from=${msg.userId} preview="${msg.text.slice(0, 60)}"`); - - const prefixContext = createReplyPrefixContext({ - cfg: msgCfg, - agentId: route.agentId, - }); - - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, - humanDelay: core.channel.reply.resolveHumanDelayConfig(msgCfg, route.agentId), - deliver: async (payload) => { - const fullText = - typeof payload.text === "string" - ? payload.text - : (payload.parts ?? []) - .map((part: string | { text?: string }) => - typeof part === "string" ? part : (part.text ?? ""), - ) - .join(""); - if (!fullText.trim()) { - return; - } - await sendNovaMessage({ - cfg: msgCfg, - to: msg.userId, - text: fullText, - replyTo: msg.messageId, - done: true, - }); - }, - onError: (err, info) => { - msgRuntime.error?.(`nova ${info.kind} reply failed: ${String(err)}`); - }, - }); - - // Auto-initialize ACP session if none exists, so messages route to - // AgentCore instead of falling through to the embedded Pi agent. - const acpSessionKey = ctxPayload.SessionKey ?? route.sessionKey; - if (acpSessionKey && msgCfg.acp?.enabled !== false && msgCfg.acp?.backend) { - const acpManager = getAcpSessionManager(); - const resolution = acpManager.resolveSession({ cfg: msgCfg, sessionKey: acpSessionKey }); - if (resolution.kind === "none") { - try { - await acpManager.initializeSession({ - cfg: msgCfg, - sessionKey: acpSessionKey, - agent: route.agentId ?? "main", - mode: "persistent", - }); - logger.info(`nova: auto-initialized ACP session for ${acpSessionKey}`); - } catch (err) { - logger.warn( - `nova: ACP session auto-init failed, falling back to embedded agent: ${String(err)}`, - ); - } - } - } - - try { - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg: msgCfg, - dispatcher, - replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, - }); - markDispatchIdle(); - logger.info(`nova: dispatch complete (queuedFinal=${queuedFinal}, final=${counts.final})`); - } catch (err) { - logger.error(`nova: dispatch failed: ${String(err)}`); - msgRuntime.error?.(`nova dispatch failed: ${String(err)}`); - } - } - - // Start the first connection attempt - connect(); - }); -} diff --git a/extensions/nova/src/onboarding.ts b/extensions/nova/src/onboarding.ts deleted file mode 100644 index 1298b03785663..0000000000000 --- a/extensions/nova/src/onboarding.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, -} from "openclaw/plugin-sdk"; -import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; -import { resolveNovaCredentials } from "./credentials.js"; - -const channel = "nova" as const; - -function setNovaDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.channels?.nova?.allowFrom)?.map((entry) => String(entry)) - : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - nova: { - ...cfg.channels?.nova, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setNovaAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - nova: { - ...cfg.channels?.nova, - allowFrom, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Nova", - channel, - policyKey: "channels.nova.dmPolicy", - allowFromKey: "channels.nova.allowFrom", - getCurrent: (cfg) => cfg.channels?.nova?.dmPolicy ?? "allowlist", - setPolicy: (cfg, policy) => setNovaDmPolicy(cfg, policy), - promptAllowFrom: async ({ cfg, prompter }) => { - const existing = cfg.channels?.nova?.allowFrom ?? []; - const entry = await prompter.text({ - message: "Nova allowFrom (user ids, comma-separated)", - placeholder: "nova-user-id-1, nova-user-id-2", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = String(entry) - .split(/[\n,;]+/g) - .map((s) => s.trim()) - .filter(Boolean); - const unique = [ - ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]), - ]; - return setNovaAllowFrom(cfg, unique); - }, -}; - -export const novaOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = Boolean(resolveNovaCredentials(cfg.channels?.nova)); - return { - channel, - configured, - statusLines: [`Nova: ${configured ? "configured" : "needs credentials"}`], - selectionHint: configured ? "configured" : "needs credentials", - quickstartScore: configured ? 2 : 0, - }; - }, - configure: async ({ cfg, prompter }) => { - const resolved = resolveNovaCredentials(cfg.channels?.nova); - let next = cfg; - let apiKey: string | null = null; - let userId: string | null = null; - let baseUrl: string | null = null; - - const hasConfigCreds = Boolean( - cfg.channels?.nova?.apiKey?.trim() && cfg.channels?.nova?.userId?.trim(), - ); - const canUseEnv = Boolean( - !hasConfigCreds && process.env.NOVA_API_KEY?.trim() && process.env.NOVA_USER_ID?.trim(), - ); - - if (!resolved) { - await prompter.note( - [ - "Configure Nova channel.", - "You need:", - "- API key (Bearer token)", - "- User ID (your Nova user identity)", - "- Base URL (optional, defaults to wss://ws.nova-claw.agi.amazon.dev)", - "Tip: set NOVA_API_KEY / NOVA_USER_ID env vars.", - ].join("\n"), - "Nova credentials", - ); - } - - if (canUseEnv) { - const keepEnv = await prompter.confirm({ - message: "NOVA_API_KEY + NOVA_USER_ID detected. Use env vars?", - initialValue: true, - }); - if (keepEnv) { - next = { - ...next, - channels: { - ...next.channels, - nova: { ...next.channels?.nova, enabled: true }, - }, - }; - } else { - apiKey = await promptApiKey(prompter); - userId = await promptUserId(prompter); - baseUrl = await promptBaseUrl(prompter); - } - } else if (hasConfigCreds) { - const keep = await prompter.confirm({ - message: "Nova credentials already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - apiKey = await promptApiKey(prompter); - userId = await promptUserId(prompter); - baseUrl = await promptBaseUrl(prompter); - } - } else { - apiKey = await promptApiKey(prompter); - userId = await promptUserId(prompter); - baseUrl = await promptBaseUrl(prompter); - } - - if (apiKey && userId) { - next = { - ...next, - channels: { - ...next.channels, - nova: { - ...next.channels?.nova, - enabled: true, - ...(baseUrl ? { baseUrl } : {}), - apiKey, - userId, - }, - }, - }; - } - - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; - }, - dmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - nova: { ...cfg.channels?.nova, enabled: false }, - }, - }), -}; - -type Prompter = Parameters[0]["prompter"]; - -async function promptApiKey(prompter: Prompter): Promise { - return String( - await prompter.text({ - message: "Nova API key", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); -} - -async function promptUserId(prompter: Prompter): Promise { - return String( - await prompter.text({ - message: "Nova user ID", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); -} - -async function promptBaseUrl(prompter: Prompter): Promise { - const value = String( - await prompter.text({ - message: "Base URL (leave empty for default)", - placeholder: "wss://ws.nova-claw.agi.amazon.dev", - }), - ).trim(); - return value || null; -} diff --git a/extensions/nova/src/outbound.ts b/extensions/nova/src/outbound.ts deleted file mode 100644 index 6333e5e155ad2..0000000000000 --- a/extensions/nova/src/outbound.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; -import { getNovaRuntime } from "./runtime.js"; -import { sendNovaMessage } from "./send.js"; - -export const novaOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: (text, limit) => getNovaRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 4000, - sendText: async ({ cfg, to, text }) => { - const result = sendNovaMessage({ cfg, to, text, done: true }); - return { channel: "nova", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl }) => { - // Send text with media URL inline (no native media embedding yet) - const body = mediaUrl ? `${text}\n${mediaUrl}` : text; - const result = sendNovaMessage({ cfg, to, text: body, done: true }); - return { channel: "nova", ...result }; - }, -}; diff --git a/extensions/nova/src/probe.test.ts b/extensions/nova/src/probe.test.ts deleted file mode 100644 index 0c7d925ba5071..0000000000000 --- a/extensions/nova/src/probe.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { probeNova } from "./probe.js"; -import type { NovaConfig } from "./types.js"; - -describe("probeNova", () => { - it("returns ok for valid config with default baseUrl", () => { - const cfg: NovaConfig = { - apiKey: "key-123", - userId: "user-001", - }; - expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); - }); - - it("returns ok for valid config with custom baseUrl", () => { - const cfg: NovaConfig = { - baseUrl: "wss://custom.example.com", - apiKey: "key-123", - userId: "user-001", - }; - expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); - }); - - it("returns error when credentials are missing", () => { - expect(probeNova({ enabled: true })).toMatchObject({ - ok: false, - error: expect.stringContaining("missing credentials"), - }); - }); - - it("returns error when credentials are undefined", () => { - expect(probeNova(undefined)).toMatchObject({ - ok: false, - error: expect.stringContaining("missing credentials"), - }); - }); - - it("returns error for non-wss baseUrl", () => { - const cfg: NovaConfig = { - baseUrl: "https://example.com/prod", - apiKey: "key-123", - userId: "user-001", - }; - const result = probeNova(cfg); - expect(result.ok).toBe(false); - expect(result.error).toContain("wss://"); - }); - - it("returns error for invalid baseUrl", () => { - const cfg: NovaConfig = { - baseUrl: "not-a-url", - apiKey: "key-123", - userId: "user-001", - }; - const result = probeNova(cfg); - expect(result.ok).toBe(false); - expect(result.error).toContain("not a valid URL"); - }); - - it("accepts ws:// protocol", () => { - const cfg: NovaConfig = { - baseUrl: "ws://localhost:8080/dev", - apiKey: "key-123", - userId: "user-001", - }; - expect(probeNova(cfg)).toEqual({ ok: true, userId: "user-001" }); - }); -}); diff --git a/extensions/nova/src/probe.ts b/extensions/nova/src/probe.ts deleted file mode 100644 index 5e4dcc7858ba7..0000000000000 --- a/extensions/nova/src/probe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolveNovaCredentials } from "./credentials.js"; -import type { NovaConfig } from "./types.js"; - -export type ProbeNovaResult = { - ok: boolean; - error?: string; - userId?: string; -}; - -/** - * Verify Nova credentials are present and the base URL is valid. - * Does not attempt an actual WS connection (that happens in the monitor). - */ -export function probeNova(cfg?: NovaConfig): ProbeNovaResult { - const creds = resolveNovaCredentials(cfg); - if (!creds) { - return { - ok: false, - error: "missing credentials (apiKey, userId)", - }; - } - - // Validate URL format - try { - const url = new URL(creds.baseUrl); - if (url.protocol !== "wss:" && url.protocol !== "ws:") { - return { - ok: false, - error: `baseUrl must use wss:// protocol (got ${url.protocol})`, - userId: creds.userId, - }; - } - } catch { - return { - ok: false, - error: "baseUrl is not a valid URL", - userId: creds.userId, - }; - } - - return { ok: true, userId: creds.userId }; -} diff --git a/extensions/nova/src/runtime.ts b/extensions/nova/src/runtime.ts deleted file mode 100644 index 64045120aff9b..0000000000000 --- a/extensions/nova/src/runtime.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; - -let runtime: PluginRuntime | null = null; - -export function setNovaRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getNovaRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Nova runtime not initialized"); - } - return runtime; -} diff --git a/extensions/nova/src/send.ts b/extensions/nova/src/send.ts deleted file mode 100644 index eaf5f5ff044a7..0000000000000 --- a/extensions/nova/src/send.ts +++ /dev/null @@ -1,47 +0,0 @@ -import WebSocket from "ws"; -import { getActiveNovaConnection } from "./connection.js"; -import { resolveNovaCredentials } from "./credentials.js"; -import type { NovaConfig } from "./types.js"; - -export type SendNovaMessageOpts = { - cfg: { channels?: { nova?: NovaConfig } }; - to: string; - text: string; - replyTo?: string; - done?: boolean; -}; - -export type SendNovaMessageResult = { - messageId: string; - conversationId: string; -}; - -/** - * Send a response frame to the Nova backend via the active WebSocket connection. - * `to` is the Nova userId, `replyTo` is the inbound messageId being replied to. - */ -export function sendNovaMessage(opts: SendNovaMessageOpts): SendNovaMessageResult { - const ws = getActiveNovaConnection(); - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Nova WebSocket connection is not open"); - } - - const creds = resolveNovaCredentials(opts.cfg.channels?.nova); - if (!creds) { - throw new Error("Nova credentials not configured"); - } - - const messageId = crypto.randomUUID(); - const frame = JSON.stringify({ - action: "response", - type: opts.done !== false ? "done" : "chunk", - text: opts.text, - messageId, - replyTo: opts.replyTo ?? "", - to: opts.to, - }); - - ws.send(frame); - - return { messageId, conversationId: opts.to }; -} diff --git a/extensions/nova/src/types.ts b/extensions/nova/src/types.ts deleted file mode 100644 index f552577272e00..0000000000000 --- a/extensions/nova/src/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** Nova channel configuration stored under `channels.nova`. */ -export type NovaConfig = { - enabled?: boolean; - baseUrl?: string; - apiKey?: string; - userId?: string; - deviceId?: string; - dmPolicy?: string; - allowFrom?: Array; - reconnectBaseDelayMs?: number; - heartbeatIntervalMs?: number; -}; - -/** Fully resolved credentials (all required fields present). */ -export type NovaCredentials = { - baseUrl: string; - apiKey: string; - userId: string; - deviceId: string; -}; - -/** Inbound message pushed to OpenClaw via WebSocket. */ -export type NovaInboundMessage = { - action: "message"; - userId: string; - text: string; - messageId: string; - timestamp: number; -}; - -/** Outbound response frame sent by OpenClaw via WebSocket. */ -export type NovaOutboundFrame = { - action: "response"; - type: "chunk" | "done"; - text: string; - messageId: string; - replyTo: string; -}; - -/** Heartbeat frame sent periodically to keep the WebSocket connection alive. */ -export type NovaHeartbeatFrame = { - action: "ping"; - timestamp: number; -}; - -/** Any frame OpenClaw sends over the WebSocket. */ -export type NovaOutgoingFrame = NovaOutboundFrame | NovaHeartbeatFrame; diff --git a/src/hyperion/channel-identity-resolver.ts b/src/hyperion/channel-identity-resolver.ts deleted file mode 100644 index b5b921580bceb..0000000000000 --- a/src/hyperion/channel-identity-resolver.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { TenantConfigLoader } from "./tenant-config-loader.js"; -import { - DEFAULT_AGENT_ID, - type CachedChannelIdentity, - type ChannelIdentityResolution, - type ChannelLink, - type HyperionPlatform, -} from "./types.js"; - -/** Identity cache TTL: 5 minutes. */ -const IDENTITY_CACHE_TTL_MS = 5 * 60_000; - -/** Maximum identity cache entries. */ -const IDENTITY_CACHE_MAX_SIZE = 50_000; - -/** - * Resolves external channel messages to internal tenant identities. - * - * This is the critical path for inbound webhook messages: - * External message arrives → resolve platform_user_id → get user_id → load config - * - * Replaces OpenClaw's in-memory allowFrom/pairing matching with DynamoDB lookups. - * The channel_config table maps (platform, platform_user_id) → user_id. - * - * Caching: in-memory with 5-minute TTL per (platform, platform_user_id). - */ -export class ChannelIdentityResolver { - private readonly dbClient: HyperionDynamoDBClient; - private readonly configLoader: TenantConfigLoader; - private readonly cache = new Map(); - - constructor(dbClient: HyperionDynamoDBClient, configLoader: TenantConfigLoader) { - this.dbClient = dbClient; - this.configLoader = configLoader; - } - - /** - * Resolve an inbound channel message to a tenant. - * - * Flow: - * 1. Look up channel_config by (platform, platform_user_id) - * 2. Extract user_id from the link - * 3. Load the full tenant config (with all channel configs assembled) - * - * @returns ChannelIdentityResolution or null if not paired. - */ - async resolve( - platform: HyperionPlatform, - platformUserId: string, - ): Promise { - const channelLink = await this.getChannelLink(platform, platformUserId); - if (!channelLink) { - return null; - } - - // [claude-infra] Multi-instance: load config for the specific agent instance - // the channel is bound to. - const agentId = channelLink.agent_id || DEFAULT_AGENT_ID; - const config = await this.configLoader.loadTenantConfig(channelLink.user_id, agentId); - - return { - user_id: channelLink.user_id, - agent_id: agentId, - channelLink, - config, - }; - } - - /** - * Resolve only the user_id for a given platform identity. - * Lighter-weight than full resolve() when you don't need the config. - */ - async resolveUserId(platform: HyperionPlatform, platformUserId: string): Promise { - const channelLink = await this.getChannelLink(platform, platformUserId); - return channelLink?.user_id ?? null; - } - - /** - * Get all channel links for a specific user. - * Useful for the portal "Connected Channels" UI. - */ - async getLinksForUser(userId: string): Promise { - return this.dbClient.getChannelLinksForUser(userId); - } - - /** - * Invalidate the identity cache for a specific channel. - * Call this when a channel is paired or unpaired. - */ - invalidateCache(platform: HyperionPlatform, platformUserId: string): void { - this.cache.delete(this.cacheKey(platform, platformUserId)); - } - - /** - * Clear the entire identity cache. - */ - clearCache(): void { - this.cache.clear(); - } - - private async getChannelLink( - platform: HyperionPlatform, - platformUserId: string, - ): Promise { - const key = this.cacheKey(platform, platformUserId); - const cached = this.cache.get(key); - if (cached && Date.now() - cached.cachedAt < IDENTITY_CACHE_TTL_MS) { - return cached.channelLink; - } - - const channelLink = await this.dbClient.getChannelLink(platform, platformUserId); - if (!channelLink) { - return null; - } - - // Evict oldest if full. - if (this.cache.size >= IDENTITY_CACHE_MAX_SIZE) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey) { - this.cache.delete(oldestKey); - } - } - - this.cache.set(key, { channelLink, cachedAt: Date.now() }); - return channelLink; - } - - private cacheKey(platform: HyperionPlatform, platformUserId: string): string { - return `${platform}:${platformUserId}`; - } -} diff --git a/src/hyperion/dynamodb-client.test.ts b/src/hyperion/dynamodb-client.test.ts deleted file mode 100644 index dfdbcf4a26af1..0000000000000 --- a/src/hyperion/dynamodb-client.test.ts +++ /dev/null @@ -1,577 +0,0 @@ -// @vitest-pool threads -// ↑ vi.mock for dynamic `await import()` requires threads pool (forks doesn't intercept). -import { describe, expect, it, vi, beforeEach } from "vitest"; - -// Mock AWS SDK commands — the source uses dynamic imports which fail without the package installed. -// Each command class just stores its input for assertion. -class MockCommand { - input: unknown; - constructor(input: unknown) { - this.input = input; - } -} -vi.mock("@aws-sdk/lib-dynamodb", () => ({ - GetCommand: class extends MockCommand {}, - PutCommand: class extends MockCommand {}, - DeleteCommand: class extends MockCommand {}, - QueryCommand: class extends MockCommand {}, -})); - -import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; -import type { - HyperionDynamoDBConfig, - TenantConfig, - PairingCode, - UserCredentialsRecord, -} from "./types.js"; -import { DEFAULT_AGENT_ID } from "./types.js"; - -const TEST_CONFIG: HyperionDynamoDBConfig = { - region: "us-west-2", - tenantConfigTableName: "hyperion-test-tenant-config", - channelConfigTableName: "hyperion-test-channel-config", - pairingCodesTableName: "hyperion-test-pairing-codes", - userCredentialsTableName: "hyperion-test-user-credentials", - credentialsKmsKeyId: "arn:aws:kms:us-west-2:123456789012:key/test-key-id", - channelConfigUserIdIndexName: "user-id-index", -}; - -function createMockDocClient(): DynamoDBDocClient & { send: ReturnType } { - return { send: vi.fn() }; -} - -describe("HyperionDynamoDBClient", () => { - let mockDocClient: ReturnType; - let client: HyperionDynamoDBClient; - - beforeEach(() => { - mockDocClient = createMockDocClient(); - client = new HyperionDynamoDBClient(TEST_CONFIG, mockDocClient); - }); - - // -- getTenantConfig -- - - describe("getTenantConfig", () => { - it("returns the item when found", async () => { - const tenantConfig: TenantConfig = { - user_id: "user-1", - agent_id: "main", - display_name: "Test User", - plan: "pro", - }; - mockDocClient.send.mockResolvedValueOnce({ Item: tenantConfig }); - - const result = await client.getTenantConfig("user-1"); - - expect(result).toEqual(tenantConfig); - expect(mockDocClient.send).toHaveBeenCalledOnce(); - }); - - it("returns null when item is not found", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getTenantConfig("nonexistent-user"); - - expect(result).toBeNull(); - }); - - it("uses the correct table name and composite key", async () => { - mockDocClient.send.mockResolvedValueOnce({ Item: { user_id: "u1", agent_id: "helper" } }); - - await client.getTenantConfig("u1", "helper"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-tenant-config", - Key: { user_id: "u1", agent_id: "helper" }, - }); - }); - - it("defaults agentId to DEFAULT_AGENT_ID when not provided", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.getTenantConfig("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); - }); - }); - - // -- listTenantAgents -- - - describe("listTenantAgents", () => { - it("returns items array from query", async () => { - const agents: TenantConfig[] = [ - { user_id: "u1", agent_id: "main" }, - { user_id: "u1", agent_id: "work" }, - ]; - mockDocClient.send.mockResolvedValueOnce({ Items: agents }); - - const result = await client.listTenantAgents("u1"); - - expect(result).toEqual(agents); - expect(result).toHaveLength(2); - }); - - it("returns empty array when no items found", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.listTenantAgents("u1"); - - expect(result).toEqual([]); - }); - - it("queries the correct table with user_id", async () => { - mockDocClient.send.mockResolvedValueOnce({ Items: [] }); - - await client.listTenantAgents("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-tenant-config", - KeyConditionExpression: "user_id = :uid", - ExpressionAttributeValues: { ":uid": "u1" }, - }); - }); - }); - - // -- putTenantConfig -- - - describe("putTenantConfig", () => { - it("sets updated_at timestamp", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - const before = new Date().toISOString(); - - await client.putTenantConfig({ user_id: "u1", agent_id: "main" }); - - const command = mockDocClient.send.mock.calls[0][0]; - const item = command.input.Item; - expect(item.updated_at).toBeDefined(); - // updated_at should be a recent ISO timestamp - const updatedAt = new Date(item.updated_at).getTime(); - expect(updatedAt).toBeGreaterThanOrEqual(new Date(before).getTime()); - expect(updatedAt).toBeLessThanOrEqual(Date.now()); - }); - - it("defaults agent_id to DEFAULT_AGENT_ID when falsy", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putTenantConfig({ user_id: "u1", agent_id: "" }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Item.agent_id).toBe(DEFAULT_AGENT_ID); - }); - - it("preserves explicit agent_id", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putTenantConfig({ user_id: "u1", agent_id: "work-helper" }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Item.agent_id).toBe("work-helper"); - }); - - it("writes to the correct table", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putTenantConfig({ user_id: "u1", agent_id: "main" }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.TableName).toBe("hyperion-test-tenant-config"); - }); - }); - - // -- deleteTenantConfig -- - - describe("deleteTenantConfig", () => { - it("deletes with correct key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deleteTenantConfig("u1", "work"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-tenant-config", - Key: { user_id: "u1", agent_id: "work" }, - }); - }); - - it("defaults agentId to DEFAULT_AGENT_ID", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deleteTenantConfig("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); - }); - }); - - // -- getChannelLink -- - - describe("getChannelLink", () => { - it("returns channel link when found", async () => { - const link = { - platform: "telegram" as const, - platform_user_id: "tg-123", - user_id: "u1", - agent_id: "main", - paired_at: "2025-01-01T00:00:00Z", - channel_account_id: "bot-1", - channel_config: {}, - }; - mockDocClient.send.mockResolvedValueOnce({ Item: link }); - - const result = await client.getChannelLink("telegram", "tg-123"); - - expect(result).toEqual(link); - }); - - it("returns null when not found", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getChannelLink("slack", "unknown"); - - expect(result).toBeNull(); - }); - - it("uses correct table and composite key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.getChannelLink("discord", "disc-456"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-channel-config", - Key: { platform: "discord", platform_user_id: "disc-456" }, - }); - }); - }); - - // -- getChannelLinksForUser -- - - describe("getChannelLinksForUser", () => { - it("queries GSI with correct index name", async () => { - mockDocClient.send.mockResolvedValueOnce({ Items: [] }); - - await client.getChannelLinksForUser("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-channel-config", - IndexName: "user-id-index", - KeyConditionExpression: "user_id = :uid", - ExpressionAttributeValues: { ":uid": "u1" }, - }); - }); - - it("returns empty array when no links found", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getChannelLinksForUser("u1"); - - expect(result).toEqual([]); - }); - }); - - // -- putChannelLink -- - - describe("putChannelLink", () => { - it("writes channel link to correct table", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - const link = { - platform: "telegram" as const, - platform_user_id: "tg-123", - user_id: "u1", - agent_id: "main", - paired_at: "2025-01-01T00:00:00Z", - channel_account_id: "bot-1", - channel_config: {}, - }; - - await client.putChannelLink(link); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.TableName).toBe("hyperion-test-channel-config"); - expect(command.input.Item).toEqual(link); - }); - }); - - // -- deleteChannelLink -- - - describe("deleteChannelLink", () => { - it("deletes with correct key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deleteChannelLink("whatsapp", "wa-789"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-channel-config", - Key: { platform: "whatsapp", platform_user_id: "wa-789" }, - }); - }); - }); - - // -- getPairingCode -- - - describe("getPairingCode", () => { - it("returns code when not expired", async () => { - const futureExpiry = Math.floor(Date.now() / 1000) + 300; // 5 min from now - const pairingCode: PairingCode = { - code: "ABC123", - user_id: "u1", - agent_id: "main", - platform: "telegram", - created_at: "2025-01-01T00:00:00Z", - expires_at: futureExpiry, - }; - mockDocClient.send.mockResolvedValueOnce({ Item: pairingCode }); - - const result = await client.getPairingCode("ABC123"); - - expect(result).toEqual(pairingCode); - }); - - it("returns null for expired codes", async () => { - const pastExpiry = Math.floor(Date.now() / 1000) - 60; // 1 min ago - const pairingCode: PairingCode = { - code: "EXPIRED1", - user_id: "u1", - agent_id: "main", - platform: "telegram", - created_at: "2025-01-01T00:00:00Z", - expires_at: pastExpiry, - }; - mockDocClient.send.mockResolvedValueOnce({ Item: pairingCode }); - - const result = await client.getPairingCode("EXPIRED1"); - - expect(result).toBeNull(); - }); - - it("returns null when code not found in DynamoDB", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getPairingCode("NONEXIST"); - - expect(result).toBeNull(); - }); - - it("uses correct table and key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.getPairingCode("CODE1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-pairing-codes", - Key: { code: "CODE1" }, - }); - }); - }); - - // -- putPairingCode -- - - describe("putPairingCode", () => { - it("writes with ConditionExpression to prevent overwrites", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - const pairingCode: PairingCode = { - code: "NEW123", - user_id: "u1", - agent_id: "main", - platform: "slack", - created_at: "2025-01-01T00:00:00Z", - expires_at: Math.floor(Date.now() / 1000) + 300, - }; - - await client.putPairingCode(pairingCode); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.TableName).toBe("hyperion-test-pairing-codes"); - expect(command.input.Item).toEqual(pairingCode); - expect(command.input.ConditionExpression).toBe("attribute_not_exists(code)"); - }); - }); - - // -- deletePairingCode -- - - describe("deletePairingCode", () => { - it("deletes with correct key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deletePairingCode("CODE1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-pairing-codes", - Key: { code: "CODE1" }, - }); - }); - }); - - // -- getUserCredentials -- - - describe("getUserCredentials", () => { - const credRecord: UserCredentialsRecord = { - user_id: "u1", - agent_id: "work", - credentials_blob: "encrypted-blob", - kms_key_id: "key-1", - updated_at: "2025-01-01T00:00:00Z", - }; - - it("returns agent-specific credentials when found", async () => { - mockDocClient.send.mockResolvedValueOnce({ Item: credRecord }); - - const result = await client.getUserCredentials("u1", "work"); - - expect(result).toEqual(credRecord); - // Should only call send once (no fallback needed) - expect(mockDocClient.send).toHaveBeenCalledOnce(); - }); - - it("falls back to __shared__ when agent-specific not found", async () => { - const sharedRecord: UserCredentialsRecord = { - user_id: "u1", - agent_id: "__shared__", - credentials_blob: "shared-blob", - kms_key_id: "key-1", - updated_at: "2025-01-01T00:00:00Z", - }; - // First call: agent-specific not found - mockDocClient.send.mockResolvedValueOnce({}); - // Second call: __shared__ found - mockDocClient.send.mockResolvedValueOnce({ Item: sharedRecord }); - - const result = await client.getUserCredentials("u1", "work"); - - expect(result).toEqual(sharedRecord); - expect(mockDocClient.send).toHaveBeenCalledTimes(2); - - // Verify first call was for agent-specific - const firstCommand = mockDocClient.send.mock.calls[0][0]; - expect(firstCommand.input.Key).toEqual({ user_id: "u1", agent_id: "work" }); - - // Verify second call was for __shared__ - const secondCommand = mockDocClient.send.mock.calls[1][0]; - expect(secondCommand.input.Key).toEqual({ user_id: "u1", agent_id: "__shared__" }); - }); - - it("returns null when neither agent-specific nor __shared__ found", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getUserCredentials("u1", "work"); - - expect(result).toBeNull(); - expect(mockDocClient.send).toHaveBeenCalledTimes(2); - }); - - it("does NOT fall back when agentId is already __shared__", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - const result = await client.getUserCredentials("u1", "__shared__"); - - expect(result).toBeNull(); - // Should only call send once — no fallback to __shared__ when already querying __shared__ - expect(mockDocClient.send).toHaveBeenCalledOnce(); - }); - - it("defaults agentId to DEFAULT_AGENT_ID", async () => { - mockDocClient.send.mockResolvedValueOnce({ - Item: { ...credRecord, agent_id: DEFAULT_AGENT_ID }, - }); - - await client.getUserCredentials("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); - }); - - it("uses the correct table for all calls", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - mockDocClient.send.mockResolvedValueOnce({}); - - await client.getUserCredentials("u1", "custom-agent"); - - const firstCommand = mockDocClient.send.mock.calls[0][0]; - const secondCommand = mockDocClient.send.mock.calls[1][0]; - expect(firstCommand.input.TableName).toBe("hyperion-test-user-credentials"); - expect(secondCommand.input.TableName).toBe("hyperion-test-user-credentials"); - }); - }); - - // -- putUserCredentials -- - - describe("putUserCredentials", () => { - it("defaults agent_id when falsy", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putUserCredentials({ - user_id: "u1", - agent_id: "", - credentials_blob: "blob", - kms_key_id: "key-1", - updated_at: "2025-01-01T00:00:00Z", - }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Item.agent_id).toBe(DEFAULT_AGENT_ID); - }); - - it("preserves explicit agent_id", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putUserCredentials({ - user_id: "u1", - agent_id: "custom", - credentials_blob: "blob", - kms_key_id: "key-1", - updated_at: "2025-01-01T00:00:00Z", - }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Item.agent_id).toBe("custom"); - }); - - it("writes to the correct table", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.putUserCredentials({ - user_id: "u1", - agent_id: "main", - credentials_blob: "blob", - kms_key_id: "key-1", - updated_at: "2025-01-01T00:00:00Z", - }); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.TableName).toBe("hyperion-test-user-credentials"); - }); - }); - - // -- deleteUserCredentials -- - - describe("deleteUserCredentials", () => { - it("deletes with correct key", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deleteUserCredentials("u1", "work"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input).toEqual({ - TableName: "hyperion-test-user-credentials", - Key: { user_id: "u1", agent_id: "work" }, - }); - }); - - it("defaults agentId to DEFAULT_AGENT_ID", async () => { - mockDocClient.send.mockResolvedValueOnce({}); - - await client.deleteUserCredentials("u1"); - - const command = mockDocClient.send.mock.calls[0][0]; - expect(command.input.Key).toEqual({ user_id: "u1", agent_id: DEFAULT_AGENT_ID }); - }); - }); -}); diff --git a/src/hyperion/dynamodb-client.ts b/src/hyperion/dynamodb-client.ts deleted file mode 100644 index 8014c25b9a5c4..0000000000000 --- a/src/hyperion/dynamodb-client.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { - DEFAULT_AGENT_ID, - type ChannelLink, - type HyperionDynamoDBConfig, - type HyperionPlatform, - type PairingCode, - type TenantConfig, - type UserCredentialsRecord, -} from "./types.js"; - -/** - * Minimal DynamoDB document client interface. - * Accepts any AWS SDK v3 DynamoDBDocumentClient-compatible implementation. - */ -export type DynamoDBDocClient = { - send(command: unknown): Promise; -}; - -/** - * Wraps DynamoDB operations for Hyperion's three tables. - * Designed to work with AWS SDK v3 DynamoDBDocumentClient. - */ -export class HyperionDynamoDBClient { - private readonly config: HyperionDynamoDBConfig; - private readonly docClient: DynamoDBDocClient; - - constructor(config: HyperionDynamoDBConfig, docClient: DynamoDBDocClient) { - this.config = config; - this.docClient = docClient; - } - - // -- Tenant Config -- [claude-infra] composite key: user_id + agent_id - - async getTenantConfig( - userId: string, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); - const result = await this.docClient.send( - new GetCommand({ - TableName: this.config.tenantConfigTableName, - Key: { user_id: userId, agent_id: agentId }, - }), - ); - const item = (result as { Item?: TenantConfig }).Item; - return item ?? null; - } - - async listTenantAgents(userId: string): Promise { - const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); - const result = await this.docClient.send( - new QueryCommand({ - TableName: this.config.tenantConfigTableName, - KeyConditionExpression: "user_id = :uid", - ExpressionAttributeValues: { ":uid": userId }, - }), - ); - return (result as { Items?: TenantConfig[] }).Items ?? []; - } - - async putTenantConfig(tenantConfig: TenantConfig): Promise { - const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new PutCommand({ - TableName: this.config.tenantConfigTableName, - Item: { - ...tenantConfig, - agent_id: tenantConfig.agent_id || DEFAULT_AGENT_ID, - updated_at: new Date().toISOString(), - }, - }), - ); - } - - async deleteTenantConfig(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { - const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new DeleteCommand({ - TableName: this.config.tenantConfigTableName, - Key: { user_id: userId, agent_id: agentId }, - }), - ); - } - - // -- Channel Config -- - - async getChannelLink( - platform: HyperionPlatform, - platformUserId: string, - ): Promise { - const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); - const result = await this.docClient.send( - new GetCommand({ - TableName: this.config.channelConfigTableName, - Key: { platform, platform_user_id: platformUserId }, - }), - ); - const item = (result as { Item?: ChannelLink }).Item; - return item ?? null; - } - - async getChannelLinksForUser(userId: string): Promise { - const { QueryCommand } = await import("@aws-sdk/lib-dynamodb"); - const result = await this.docClient.send( - new QueryCommand({ - TableName: this.config.channelConfigTableName, - IndexName: this.config.channelConfigUserIdIndexName, - KeyConditionExpression: "user_id = :uid", - ExpressionAttributeValues: { ":uid": userId }, - }), - ); - const items = (result as { Items?: ChannelLink[] }).Items; - return items ?? []; - } - - async putChannelLink(channelLink: ChannelLink): Promise { - const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new PutCommand({ - TableName: this.config.channelConfigTableName, - Item: channelLink, - }), - ); - } - - async deleteChannelLink(platform: HyperionPlatform, platformUserId: string): Promise { - const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new DeleteCommand({ - TableName: this.config.channelConfigTableName, - Key: { platform, platform_user_id: platformUserId }, - }), - ); - } - - // -- Pairing Codes -- - - async getPairingCode(code: string): Promise { - const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); - const result = await this.docClient.send( - new GetCommand({ - TableName: this.config.pairingCodesTableName, - Key: { code }, - }), - ); - const item = (result as { Item?: PairingCode }).Item; - if (!item) { - return null; - } - // DynamoDB TTL is eventually consistent — check expiry explicitly. - if (item.expires_at <= Math.floor(Date.now() / 1000)) { - return null; - } - return item; - } - - async putPairingCode(pairingCode: PairingCode): Promise { - const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new PutCommand({ - TableName: this.config.pairingCodesTableName, - Item: pairingCode, - ConditionExpression: "attribute_not_exists(code)", - }), - ); - } - - async deletePairingCode(code: string): Promise { - const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new DeleteCommand({ - TableName: this.config.pairingCodesTableName, - Key: { code }, - }), - ); - } - - /** - * Atomically consume a pairing code: deletes it only if it still exists. - * Returns the deleted item, or null if it was already consumed. - * Uses ConditionExpression to prevent double-redemption races. - */ - async consumePairingCode(code: string): Promise { - const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); - try { - const result = await this.docClient.send( - new DeleteCommand({ - TableName: this.config.pairingCodesTableName, - Key: { code }, - ConditionExpression: "attribute_exists(code)", - ReturnValues: "ALL_OLD", - }), - ); - const item = (result as { Attributes?: PairingCode }).Attributes; - if (!item) { - return null; - } - // DynamoDB TTL is eventually consistent — check expiry explicitly. - if (item.expires_at <= Math.floor(Date.now() / 1000)) { - return null; - } - return item; - } catch (err) { - if ((err as { name?: string }).name === "ConditionalCheckFailedException") { - return null; - } - throw err; - } - } - - // -- User Credentials -- [claude-infra] composite key: user_id + agent_id - - async getUserCredentials( - userId: string, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - const { GetCommand } = await import("@aws-sdk/lib-dynamodb"); - // Try agent-specific credentials first, then shared. - const result = await this.docClient.send( - new GetCommand({ - TableName: this.config.userCredentialsTableName, - Key: { user_id: userId, agent_id: agentId }, - }), - ); - const item = (result as { Item?: UserCredentialsRecord }).Item; - if (item) { - return item; - } - - // Fall back to shared credentials if agent-specific not found. - if (agentId !== "__shared__") { - const sharedResult = await this.docClient.send( - new GetCommand({ - TableName: this.config.userCredentialsTableName, - Key: { user_id: userId, agent_id: "__shared__" }, - }), - ); - return (sharedResult as { Item?: UserCredentialsRecord }).Item ?? null; - } - return null; - } - - async putUserCredentials(record: UserCredentialsRecord): Promise { - const { PutCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new PutCommand({ - TableName: this.config.userCredentialsTableName, - Item: { - ...record, - agent_id: record.agent_id || DEFAULT_AGENT_ID, - }, - }), - ); - } - - async deleteUserCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { - const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb"); - await this.docClient.send( - new DeleteCommand({ - TableName: this.config.userCredentialsTableName, - Key: { user_id: userId, agent_id: agentId }, - }), - ); - } -} diff --git a/src/hyperion/index.ts b/src/hyperion/index.ts deleted file mode 100644 index 5a6cc74307df8..0000000000000 --- a/src/hyperion/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Hyperion Integration Layer for OpenClaw - * - * This module replaces OpenClaw's single-tenant filesystem-based configuration - * with a multi-tenant DynamoDB-backed implementation for the Nova Personal - * Assistant Platform (assistant.nova.amazon.com). - * - * Architecture: - * - * OpenClaw (single-tenant) Hyperion (multi-tenant) - * ───────────────────────── ─────────────────────────── - * openclaw.json5 on disk → tenant_config DynamoDB table - * {channel}-pairing.json → pairing_codes DynamoDB table (TTL) - * {channel}-allowFrom.json → channel_config DynamoDB table - * session keys: "main" → session keys: "tenant_{userId}:{agentId}:main" - * in-memory config cache → in-memory LRU with 1-min TTL - * file lock concurrency → DynamoDB conditional writes - * - * Entry points: - * - TenantConfigLoader: loadConfig() replacement (per-tenant from DynamoDB) - * - ChannelIdentityResolver: webhook identity resolution (platform_user_id → user_id) - * - HyperionPairingStore: pairing-store.ts replacement (DynamoDB-backed) - * - Session helpers: tenant-scoped session key management - * - HyperionDynamoDBClient: DynamoDB operations for all three tables - * - createHyperionRuntime: one-call setup of the full integration layer - */ - -// Types -export { DEFAULT_AGENT_ID } from "./types.js"; -export type { - ChannelIdentityResolution, - ChannelLink, - ChannelRuntimeConfig, - CachedChannelIdentity, - CachedTenantConfig, - HyperionDynamoDBConfig, - HyperionPlatform, - PairingCode, - TenantConfig, -} from "./types.js"; - -// DynamoDB client -export { HyperionDynamoDBClient } from "./dynamodb-client.js"; -export type { DynamoDBDocClient } from "./dynamodb-client.js"; - -// Config loader (replaces io.ts loadConfig) -export { TenantConfigLoader, TenantNotFoundError } from "./tenant-config-loader.js"; - -// Identity resolution (replaces channel-config.ts resolution + pairing allowFrom) -export { ChannelIdentityResolver } from "./channel-identity-resolver.js"; - -// Pairing store (replaces pairing-store.ts file-based store) -export { HyperionPairingStore } from "./pairing-store.js"; - -// Session management (replaces session.ts with tenant-scoped keys) -export { - buildPortalSessionKey, - buildChannelSessionKey, - buildTenantMemoryNamespace, - extractAgentId, - extractInnerSessionKey, - extractTenantId, - isSessionForAgent, - isSessionForTenant, -} from "./session-manager.js"; - -// Runtime factory -export { createHyperionRuntime, type HyperionRuntime } from "./runtime.js"; diff --git a/src/hyperion/pairing-store.test.ts b/src/hyperion/pairing-store.test.ts deleted file mode 100644 index 311407201c330..0000000000000 --- a/src/hyperion/pairing-store.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { HyperionPairingStore } from "./pairing-store.js"; -import { DEFAULT_AGENT_ID } from "./types.js"; - -function createMockDbClient() { - return { - putPairingCode: vi.fn(), - getPairingCode: vi.fn(), - consumePairingCode: vi.fn(), - deletePairingCode: vi.fn(), - putChannelLink: vi.fn(), - deleteChannelLink: vi.fn(), - } as unknown as HyperionDynamoDBClient & { - putPairingCode: ReturnType; - getPairingCode: ReturnType; - consumePairingCode: ReturnType; - deletePairingCode: ReturnType; - putChannelLink: ReturnType; - deleteChannelLink: ReturnType; - }; -} - -describe("HyperionPairingStore", () => { - let dbClient: ReturnType; - let store: HyperionPairingStore; - - beforeEach(() => { - dbClient = createMockDbClient(); - store = new HyperionPairingStore(dbClient); - }); - - describe("generatePairingCode", () => { - it("returns a code on success", async () => { - dbClient.putPairingCode.mockResolvedValueOnce(undefined); - - const code = await store.generatePairingCode("user-1", "telegram"); - - expect(code).toBeTruthy(); - expect(typeof code).toBe("string"); - expect(code!.length).toBe(8); - expect(dbClient.putPairingCode).toHaveBeenCalledOnce(); - const savedCode = dbClient.putPairingCode.mock.calls[0][0]; - expect(savedCode.user_id).toBe("user-1"); - expect(savedCode.platform).toBe("telegram"); - expect(savedCode.agent_id).toBe(DEFAULT_AGENT_ID); - }); - - it("retries on ConditionalCheckFailedException", async () => { - const conditionalError = new Error("Conditional check failed"); - conditionalError.name = "ConditionalCheckFailedException"; - - dbClient.putPairingCode - .mockRejectedValueOnce(conditionalError) - .mockRejectedValueOnce(conditionalError) - .mockResolvedValueOnce(undefined); - - const code = await store.generatePairingCode("user-1", "telegram"); - - expect(code).toBeTruthy(); - expect(dbClient.putPairingCode).toHaveBeenCalledTimes(3); - }); - - it("returns null after 5 failed attempts", async () => { - const conditionalError = new Error("Conditional check failed"); - conditionalError.name = "ConditionalCheckFailedException"; - - dbClient.putPairingCode.mockRejectedValue(conditionalError); - - const code = await store.generatePairingCode("user-1", "telegram"); - - expect(code).toBeNull(); - expect(dbClient.putPairingCode).toHaveBeenCalledTimes(5); - }); - - it("passes agentId through to PairingCode", async () => { - dbClient.putPairingCode.mockResolvedValueOnce(undefined); - - const code = await store.generatePairingCode("user-1", "slack", "work-agent"); - - expect(code).toBeTruthy(); - const savedCode = dbClient.putPairingCode.mock.calls[0][0]; - expect(savedCode.agent_id).toBe("work-agent"); - }); - - it("throws on non-conditional errors", async () => { - const genericError = new Error("DynamoDB is down"); - genericError.name = "InternalServerError"; - - dbClient.putPairingCode.mockRejectedValueOnce(genericError); - - await expect(store.generatePairingCode("user-1", "telegram")).rejects.toThrow( - "DynamoDB is down", - ); - expect(dbClient.putPairingCode).toHaveBeenCalledOnce(); - }); - }); - - describe("redeemPairingCode", () => { - const basePairingCode = { - code: "ABCD1234", - user_id: "user-1", - agent_id: "work-agent", - platform: "telegram" as const, - created_at: "2026-01-01T00:00:00.000Z", - expires_at: Math.floor(Date.now() / 1000) + 300, - }; - - it("creates ChannelLink with correct data, inherits agent_id from pairing code", async () => { - dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); - dbClient.putChannelLink.mockResolvedValueOnce(undefined); - - const link = await store.redeemPairingCode({ - code: "ABCD1234", - platform: "telegram", - platformUserId: "tg-user-99", - channelAccountId: "bot-account", - channelConfig: { some: "config" }, - }); - - expect(link).not.toBeNull(); - expect(link!.platform).toBe("telegram"); - expect(link!.platform_user_id).toBe("tg-user-99"); - expect(link!.user_id).toBe("user-1"); - expect(link!.agent_id).toBe("work-agent"); - expect(link!.channel_account_id).toBe("bot-account"); - expect(link!.channel_config).toEqual({ some: "config" }); - expect(link!.paired_at).toBeTruthy(); - expect(dbClient.putChannelLink).toHaveBeenCalledOnce(); - }); - - it("normalizes code to uppercase", async () => { - dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); - dbClient.putChannelLink.mockResolvedValueOnce(undefined); - - await store.redeemPairingCode({ - code: " abcd1234 ", - platform: "telegram", - platformUserId: "tg-user-99", - }); - - expect(dbClient.consumePairingCode).toHaveBeenCalledWith("ABCD1234"); - }); - - it("returns null for empty code", async () => { - const link = await store.redeemPairingCode({ - code: " ", - platform: "telegram", - platformUserId: "tg-user-99", - }); - - expect(link).toBeNull(); - expect(dbClient.getPairingCode).not.toHaveBeenCalled(); - }); - - it("returns null if pairing code not found (already consumed)", async () => { - dbClient.consumePairingCode.mockResolvedValueOnce(null); - - const link = await store.redeemPairingCode({ - code: "NONEXIST", - platform: "telegram", - platformUserId: "tg-user-99", - }); - - expect(link).toBeNull(); - expect(dbClient.putChannelLink).not.toHaveBeenCalled(); - }); - - it("returns null if platform doesn't match", async () => { - dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode); - - const link = await store.redeemPairingCode({ - code: "ABCD1234", - platform: "slack", - platformUserId: "slack-user-1", - }); - - expect(link).toBeNull(); - expect(dbClient.putChannelLink).not.toHaveBeenCalled(); - }); - }); - - describe("validatePairingCode", () => { - it("returns pairing code when valid", async () => { - const pairingCode = { - code: "ABCD1234", - user_id: "user-1", - agent_id: DEFAULT_AGENT_ID, - platform: "telegram" as const, - created_at: "2026-01-01T00:00:00.000Z", - expires_at: Math.floor(Date.now() / 1000) + 300, - }; - dbClient.getPairingCode.mockResolvedValueOnce(pairingCode); - - const result = await store.validatePairingCode("abcd1234", "telegram"); - - expect(result).toEqual(pairingCode); - expect(dbClient.getPairingCode).toHaveBeenCalledWith("ABCD1234"); - }); - - it("returns null on platform mismatch", async () => { - const pairingCode = { - code: "ABCD1234", - user_id: "user-1", - agent_id: DEFAULT_AGENT_ID, - platform: "telegram" as const, - created_at: "2026-01-01T00:00:00.000Z", - expires_at: Math.floor(Date.now() / 1000) + 300, - }; - dbClient.getPairingCode.mockResolvedValueOnce(pairingCode); - - const result = await store.validatePairingCode("ABCD1234", "discord"); - - expect(result).toBeNull(); - }); - }); - - describe("disconnectChannel", () => { - it("calls deleteChannelLink", async () => { - dbClient.deleteChannelLink.mockResolvedValueOnce(undefined); - - await store.disconnectChannel("telegram", "tg-user-99"); - - expect(dbClient.deleteChannelLink).toHaveBeenCalledWith("telegram", "tg-user-99"); - }); - }); -}); diff --git a/src/hyperion/pairing-store.ts b/src/hyperion/pairing-store.ts deleted file mode 100644 index 957ca2c707076..0000000000000 --- a/src/hyperion/pairing-store.ts +++ /dev/null @@ -1,162 +0,0 @@ -import crypto from "node:crypto"; -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { - DEFAULT_AGENT_ID, - type ChannelLink, - type ChannelRuntimeConfig, - type HyperionPlatform, - type PairingCode, -} from "./types.js"; - -const PAIRING_CODE_LENGTH = 8; -const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; -const PAIRING_CODE_TTL_SECONDS = 5 * 60; // 5 minutes - -/** - * DynamoDB-backed pairing store for Hyperion. - * - * Replaces OpenClaw's file-based pairing store (src/pairing/pairing-store.ts) with - * a serverless implementation using the pairing_codes DynamoDB table. - * - * Flow: - * 1. User clicks "Connect Telegram" in portal → generatePairingCode() - * 2. User sends `/connect ` to bot on Telegram → redeemPairingCode() - * 3. Code is validated, channel link is created, code is deleted - * - * Key differences from OpenClaw's file-based store: - * - No file locks needed — DynamoDB provides atomic conditional writes - * - No pruning needed — DynamoDB TTL auto-deletes expired codes - * - No max-pending limit needed — TTL-based expiry prevents unbounded growth - * - Pairing is user-initiated (portal → external), not external-initiated - */ -export class HyperionPairingStore { - private readonly dbClient: HyperionDynamoDBClient; - - constructor(dbClient: HyperionDynamoDBClient) { - this.dbClient = dbClient; - } - - /** - * Generate a pairing code for a user to connect a specific channel. - * Called from the portal when user clicks "Connect ". - * - * @returns The generated code, or null if code generation failed after retries. - */ - // [claude-infra] Multi-instance: agentId specifies which agent the channel binds to. - async generatePairingCode( - userId: string, - platform: HyperionPlatform, - agentId: string = DEFAULT_AGENT_ID, - meta?: Record, - ): Promise { - const maxAttempts = 5; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const code = randomCode(); - const pairingCode: PairingCode = { - code, - user_id: userId, - agent_id: agentId, - platform, - created_at: new Date().toISOString(), - expires_at: Math.floor(Date.now() / 1000) + PAIRING_CODE_TTL_SECONDS, - ...(meta ? { meta } : {}), - }; - - try { - await this.dbClient.putPairingCode(pairingCode); - return code; - } catch (err) { - // ConditionalCheckFailedException means code already exists — retry. - if ((err as { name?: string }).name === "ConditionalCheckFailedException") { - continue; - } - throw err; - } - } - return null; - } - - /** - * Redeem a pairing code and create the channel link. - * Called when a webhook arrives with `/connect `. - * - * @returns The created ChannelLink, or null if the code is invalid/expired. - */ - async redeemPairingCode(params: { - code: string; - platform: HyperionPlatform; - platformUserId: string; - channelAccountId?: string; - channelConfig?: ChannelRuntimeConfig; - }): Promise { - const normalizedCode = params.code.trim().toUpperCase(); - if (!normalizedCode) { - return null; - } - - // Atomically consume the pairing code — conditional delete ensures only one - // concurrent redeem succeeds, preventing double-bind race conditions. - const pairingCode = await this.dbClient.consumePairingCode(normalizedCode); - if (!pairingCode) { - return null; - } - - // Verify platform matches. - if (pairingCode.platform !== params.platform) { - return null; - } - - // [claude-infra] Multi-instance: channel link inherits agent_id from pairing code. - const channelLink: ChannelLink = { - platform: params.platform, - platform_user_id: params.platformUserId, - user_id: pairingCode.user_id, - agent_id: pairingCode.agent_id || DEFAULT_AGENT_ID, - paired_at: new Date().toISOString(), - channel_account_id: params.channelAccountId ?? "default", - channel_config: params.channelConfig ?? {}, - }; - - await this.dbClient.putChannelLink(channelLink); - - return channelLink; - } - - /** - * Validate a pairing code without consuming it. - * Useful for showing confirmation before completing pairing. - */ - async validatePairingCode(code: string, platform: HyperionPlatform): Promise { - const normalizedCode = code.trim().toUpperCase(); - if (!normalizedCode) { - return null; - } - - const pairingCode = await this.dbClient.getPairingCode(normalizedCode); - if (!pairingCode) { - return null; - } - if (pairingCode.platform !== platform) { - return null; - } - - return pairingCode; - } - - /** - * Disconnect a channel link. - * Called from the portal when user clicks "Disconnect ". - */ - async disconnectChannel(platform: HyperionPlatform, platformUserId: string): Promise { - await this.dbClient.deleteChannelLink(platform, platformUserId); - } -} - -function randomCode(): string { - let out = ""; - for (let i = 0; i < PAIRING_CODE_LENGTH; i++) { - const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length); - out += PAIRING_CODE_ALPHABET[idx]; - } - return out; -} diff --git a/src/hyperion/runtime.ts b/src/hyperion/runtime.ts deleted file mode 100644 index fa71222d41cbf..0000000000000 --- a/src/hyperion/runtime.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { OpenClawConfig } from "../config/types.js"; -import { ChannelIdentityResolver } from "./channel-identity-resolver.js"; -import { HyperionDynamoDBClient, type DynamoDBDocClient } from "./dynamodb-client.js"; -import { HyperionPairingStore } from "./pairing-store.js"; -import { TenantConfigLoader } from "./tenant-config-loader.js"; -import type { HyperionDynamoDBConfig } from "./types.js"; -import { UserCredentialStore, type KMSClient } from "./user-credential-store.js"; - -/** - * The complete Hyperion runtime — all services wired together. - */ -export type HyperionRuntime = { - /** DynamoDB operations for all tables. */ - dbClient: HyperionDynamoDBClient; - /** Loads per-tenant OpenClawConfig from DynamoDB (replaces loadConfig). */ - configLoader: TenantConfigLoader; - /** Resolves inbound webhooks to tenant identities (replaces allowFrom/pairing match). */ - identityResolver: ChannelIdentityResolver; - /** Manages pairing codes and channel linking (replaces file-based pairing store). */ - pairingStore: HyperionPairingStore; - /** Manages per-user encrypted credentials (API keys, bot tokens). */ - credentialStore: UserCredentialStore; -}; - -/** - * Create the full Hyperion runtime with a single call. - * - * Usage: - * ```ts - * import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; - * import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; - * import { KMSClient } from "@aws-sdk/client-kms"; - * import { createHyperionRuntime } from "./hyperion/index.js"; - * - * const ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); - * const kmsClient = new KMSClient({ region: "us-east-1" }); - * const runtime = createHyperionRuntime({ - * dynamoConfig: { - * region: "us-east-1", - * tenantConfigTableName: "Hyperion-prod-tenant-config", - * channelConfigTableName: "Hyperion-prod-channel-config", - * pairingCodesTableName: "Hyperion-prod-pairing-codes", - * userCredentialsTableName: "Hyperion-prod-user-credentials", - * credentialsKmsKeyId: "alias/hyperion-prod-credentials", - * channelConfigUserIdIndexName: "user_id-index", - * }, - * docClient: ddbClient, - * kmsClient: kmsClient, - * }); - * - * // Portal SSE path (credentials auto-injected into config): - * const config = await runtime.configLoader.loadTenantConfig(userId); - * - * // Store user credentials (encrypted at rest via KMS): - * await runtime.credentialStore.putCredentials(userId, { - * model_keys: { openai: "sk-..." }, - * tool_keys: { brave_search: "BSA..." }, - * }); - * - * // Webhook path: - * const identity = await runtime.identityResolver.resolve("telegram", "12345"); - * - * // Pairing: - * const code = await runtime.pairingStore.generatePairingCode(userId, "telegram"); - * ``` - */ -export function createHyperionRuntime(params: { - dynamoConfig: HyperionDynamoDBConfig; - docClient: DynamoDBDocClient; - kmsClient: KMSClient; - defaultConfig?: Partial; -}): HyperionRuntime { - const dbClient = new HyperionDynamoDBClient(params.dynamoConfig, params.docClient); - const credentialStore = new UserCredentialStore( - dbClient, - params.kmsClient, - params.dynamoConfig.credentialsKmsKeyId, - ); - const configLoader = new TenantConfigLoader(dbClient, params.defaultConfig, credentialStore); - const identityResolver = new ChannelIdentityResolver(dbClient, configLoader); - const pairingStore = new HyperionPairingStore(dbClient); - - return { - dbClient, - configLoader, - identityResolver, - pairingStore, - credentialStore, - }; -} diff --git a/src/hyperion/session-manager.test.ts b/src/hyperion/session-manager.test.ts deleted file mode 100644 index 4673b2eca6c8b..0000000000000 --- a/src/hyperion/session-manager.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildPortalSessionKey, - buildChannelSessionKey, - extractTenantId, - extractAgentId, - extractInnerSessionKey, - isSessionForTenant, - isSessionForAgent, - buildTenantMemoryNamespace, -} from "./session-manager.js"; - -describe("session-manager", () => { - describe("buildPortalSessionKey", () => { - it("uses default agentId", () => { - expect(buildPortalSessionKey("user123")).toBe("tenant_user123:main:main"); - }); - - it("uses custom agentId", () => { - expect(buildPortalSessionKey("user123", "work")).toBe("tenant_user123:work:main"); - }); - }); - - describe("buildChannelSessionKey", () => { - it("builds key without threadId", () => { - expect(buildChannelSessionKey("user123", "main", "telegram", "98765")).toBe( - "tenant_user123:main:telegram:98765", - ); - }); - - it("builds key with threadId", () => { - expect(buildChannelSessionKey("user123", "main", "slack", "U111", "T999")).toBe( - "tenant_user123:main:slack:U111:T999", - ); - }); - - it("defaults agentId to main", () => { - expect(buildChannelSessionKey("user123", undefined, "discord", "D555")).toBe( - "tenant_user123:main:discord:D555", - ); - }); - - it("uses custom agentId", () => { - expect(buildChannelSessionKey("user123", "personal", "whatsapp", "W777")).toBe( - "tenant_user123:personal:whatsapp:W777", - ); - }); - }); - - describe("extractTenantId", () => { - it("extracts userId from portal key", () => { - expect(extractTenantId("tenant_user123:main:main")).toBe("user123"); - }); - - it("extracts userId from channel key", () => { - expect(extractTenantId("tenant_abc:work:telegram:98765")).toBe("abc"); - }); - - it("returns null for non-tenant key", () => { - expect(extractTenantId("main")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(extractTenantId("")).toBeNull(); - }); - - it("returns null for tenant_ prefix with no separator", () => { - expect(extractTenantId("tenant_user123")).toBeNull(); - }); - - it("handles tenant_ prefix with immediate separator", () => { - expect(extractTenantId("tenant_:main:main")).toBe(""); - }); - }); - - describe("extractAgentId", () => { - it("extracts agentId from portal key", () => { - expect(extractAgentId("tenant_user123:main:main")).toBe("main"); - }); - - it("extracts custom agentId", () => { - expect(extractAgentId("tenant_user123:work:telegram:98765")).toBe("work"); - }); - - it("returns default for non-tenant key", () => { - expect(extractAgentId("some:other:key")).toBe("main"); - }); - - it("returns default when no separator after prefix", () => { - expect(extractAgentId("tenant_user123")).toBe("main"); - }); - - it("returns agentId when only userId and agentId present", () => { - expect(extractAgentId("tenant_user123:work")).toBe("work"); - }); - - it("returns default for empty agentId segment", () => { - expect(extractAgentId("tenant_user123::rest")).toBe("main"); - }); - }); - - describe("extractInnerSessionKey", () => { - it("extracts inner key from portal session", () => { - expect(extractInnerSessionKey("tenant_user123:main:main")).toBe("main"); - }); - - it("extracts inner key from channel session", () => { - expect(extractInnerSessionKey("tenant_user123:work:telegram:98765")).toBe("telegram:98765"); - }); - - it("extracts inner key with threadId", () => { - expect(extractInnerSessionKey("tenant_user123:main:slack:U111:T999")).toBe("slack:U111:T999"); - }); - - it("returns original key for non-tenant key", () => { - expect(extractInnerSessionKey("telegram:12345")).toBe("telegram:12345"); - }); - - it("returns original when no separator after prefix", () => { - expect(extractInnerSessionKey("tenant_user123")).toBe("tenant_user123"); - }); - - it("returns agentId when no second separator", () => { - expect(extractInnerSessionKey("tenant_user123:work")).toBe("work"); - }); - }); - - describe("isSessionForTenant", () => { - it("returns true for matching userId", () => { - expect(isSessionForTenant("tenant_user123:main:main", "user123")).toBe(true); - }); - - it("returns false for different userId", () => { - expect(isSessionForTenant("tenant_user123:main:main", "user456")).toBe(false); - }); - - it("returns false for non-tenant key", () => { - expect(isSessionForTenant("main", "user123")).toBe(false); - }); - - it("does not match partial userId prefix", () => { - expect(isSessionForTenant("tenant_user123:main:main", "user12")).toBe(false); - }); - }); - - describe("isSessionForAgent", () => { - it("returns true for matching userId and default agentId", () => { - expect(isSessionForAgent("tenant_user123:main:main", "user123")).toBe(true); - }); - - it("returns true for matching userId and custom agentId", () => { - expect(isSessionForAgent("tenant_user123:work:telegram:98765", "user123", "work")).toBe(true); - }); - - it("returns false for wrong agentId", () => { - expect(isSessionForAgent("tenant_user123:work:main", "user123", "personal")).toBe(false); - }); - - it("returns false for wrong userId", () => { - expect(isSessionForAgent("tenant_user123:main:main", "user456")).toBe(false); - }); - - it("returns false for non-tenant key", () => { - expect(isSessionForAgent("main", "user123")).toBe(false); - }); - }); - - describe("buildTenantMemoryNamespace", () => { - it("builds namespace with default agentId", () => { - expect(buildTenantMemoryNamespace("user123")).toBe("tenant_user123:main"); - }); - - it("builds namespace with custom agentId", () => { - expect(buildTenantMemoryNamespace("user123", "work")).toBe("tenant_user123:work"); - }); - }); -}); diff --git a/src/hyperion/session-manager.ts b/src/hyperion/session-manager.ts deleted file mode 100644 index 4aab48d39f538..0000000000000 --- a/src/hyperion/session-manager.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { DEFAULT_AGENT_ID, type HyperionPlatform } from "./types.js"; - -/** - * Tenant-scoped session key management for Hyperion. - * [claude-infra] Multi-instance: session keys now include agent_id. - * - * OpenClaw's sessions are identified by session keys (src/channels/session.ts, - * src/routing/session-key.ts). In single-tenant mode, session keys are simple - * channel identifiers like "telegram:12345" or "main". - * - * For multi-tenant Hyperion, all session keys are namespaced with the tenant's - * user_id and agent_id to ensure complete isolation between tenants and agents: - * - * Single-tenant OpenClaw: "main" - * Multi-tenant Hyperion: "tenant_user123:main:main" - * - * Single-tenant OpenClaw: "telegram:12345" - * Multi-tenant Hyperion: "tenant_user123:main:telegram:12345" - * - * Format: tenant_{userId}:{agentId}:{rest} - */ - -const TENANT_PREFIX = "tenant_"; -const SEPARATOR = ":"; - -/** - * Build a tenant-scoped session key for portal (synchronous) interactions. - * [claude-infra] Multi-instance: includes agentId. - * - * @param userId - Internal Hyperion user ID - * @param agentId - Agent instance ID (default: "main") - * @returns Scoped session key like "tenant_user123:main:main" - */ -export function buildPortalSessionKey(userId: string, agentId: string = DEFAULT_AGENT_ID): string { - return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}main`; -} - -/** - * Build a tenant-scoped session key for an external channel interaction. - * [claude-infra] Multi-instance: includes agentId. - * - * @param userId - Internal Hyperion user ID - * @param agentId - Agent instance ID (default: "main") - * @param platform - External platform identifier - * @param platformUserId - User's ID on the external platform - * @param threadId - Optional thread/conversation ID for threaded sessions - * @returns Scoped session key like "tenant_user123:main:telegram:98765" - */ -export function buildChannelSessionKey( - userId: string, - agentId: string = DEFAULT_AGENT_ID, - platform: HyperionPlatform, - platformUserId: string, - threadId?: string, -): string { - const base = `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}${platform}${SEPARATOR}${platformUserId}`; - if (threadId) { - return `${base}${SEPARATOR}${threadId}`; - } - return base; -} - -/** - * Extract the tenant user_id from a scoped session key. - * - * @returns The user_id, or null if the key is not tenant-scoped. - */ -export function extractTenantId(sessionKey: string): string | null { - if (!sessionKey.startsWith(TENANT_PREFIX)) { - return null; - } - const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); - const separatorIdx = afterPrefix.indexOf(SEPARATOR); - if (separatorIdx < 0) { - return null; - } - return afterPrefix.slice(0, separatorIdx); -} - -/** - * Extract the agent_id from a scoped session key. - * [claude-infra] Multi-instance support. - * - * Format: tenant_{userId}:{agentId}:{rest} - * @returns The agent_id, or DEFAULT_AGENT_ID if not found. - */ -export function extractAgentId(sessionKey: string): string { - if (!sessionKey.startsWith(TENANT_PREFIX)) { - return DEFAULT_AGENT_ID; - } - const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); - const firstSep = afterPrefix.indexOf(SEPARATOR); - if (firstSep < 0) { - return DEFAULT_AGENT_ID; - } - const afterUserId = afterPrefix.slice(firstSep + 1); - const secondSep = afterUserId.indexOf(SEPARATOR); - if (secondSep < 0) { - return afterUserId || DEFAULT_AGENT_ID; - } - return afterUserId.slice(0, secondSep) || DEFAULT_AGENT_ID; -} - -/** - * Extract the inner session key (without tenant prefix and agent_id). - * This is what gets passed to OpenClaw's session internals. - * [claude-infra] Multi-instance: strips both tenant_ prefix and agentId. - * - * @returns The inner key, or the original key if not tenant-scoped. - */ -export function extractInnerSessionKey(sessionKey: string): string { - if (!sessionKey.startsWith(TENANT_PREFIX)) { - return sessionKey; - } - const afterPrefix = sessionKey.slice(TENANT_PREFIX.length); - // Skip userId - const firstSep = afterPrefix.indexOf(SEPARATOR); - if (firstSep < 0) { - return sessionKey; - } - const afterUserId = afterPrefix.slice(firstSep + 1); - // Skip agentId - const secondSep = afterUserId.indexOf(SEPARATOR); - if (secondSep < 0) { - return afterUserId; - } - return afterUserId.slice(secondSep + 1); -} - -/** - * Check if a session key belongs to a specific tenant. - */ -export function isSessionForTenant(sessionKey: string, userId: string): boolean { - return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}`); -} - -/** - * Check if a session key belongs to a specific tenant+agent. - * [claude-infra] Multi-instance support. - */ -export function isSessionForAgent( - sessionKey: string, - userId: string, - agentId: string = DEFAULT_AGENT_ID, -): boolean { - return sessionKey.startsWith(`${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}${SEPARATOR}`); -} - -/** - * Build the AgentCore memory namespace for a tenant+agent. - * [claude-infra] Multi-instance: each agent instance has isolated memory. - */ -export function buildTenantMemoryNamespace( - userId: string, - agentId: string = DEFAULT_AGENT_ID, -): string { - return `${TENANT_PREFIX}${userId}${SEPARATOR}${agentId}`; -} diff --git a/src/hyperion/tenant-config-loader.test.ts b/src/hyperion/tenant-config-loader.test.ts deleted file mode 100644 index 9f64db8887727..0000000000000 --- a/src/hyperion/tenant-config-loader.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { TenantConfigLoader, TenantNotFoundError } from "./tenant-config-loader.js"; -import type { ChannelLink, TenantConfig } from "./types.js"; -import type { UserCredentialStore } from "./user-credential-store.js"; - -function createMockFns() { - return { - getTenantConfig: vi.fn(), - listTenantAgents: vi.fn(), - putTenantConfig: vi.fn(), - deleteTenantConfig: vi.fn(), - getChannelLink: vi.fn(), - getChannelLinksForUser: vi.fn(), - putChannelLink: vi.fn(), - deleteChannelLink: vi.fn(), - getPairingCode: vi.fn(), - putPairingCode: vi.fn(), - deletePairingCode: vi.fn(), - getUserCredentials: vi.fn(), - putUserCredentials: vi.fn(), - deleteUserCredentials: vi.fn(), - }; -} - -function createMockCredFns() { - return { - getCredentials: vi.fn(), - putCredentials: vi.fn(), - deleteCredentials: vi.fn(), - invalidateCache: vi.fn(), - clearCache: vi.fn(), - }; -} - -const baseTenantConfig: TenantConfig = { - user_id: "user1", - agent_id: "main", - model: "anthropic.claude-sonnet-4-20250514", - custom_instructions: "Be helpful", - tools: ["brave_search", "calculator"], - skills: ["web"], -}; - -const channelLink: ChannelLink = { - platform: "telegram", - platform_user_id: "tg98765", - user_id: "user1", - agent_id: "main", - paired_at: "2026-01-01T00:00:00.000Z", - channel_account_id: "bot1", - channel_config: { streaming: "partial" }, -}; - -describe("TenantConfigLoader", () => { - let mockDb: ReturnType; - let mockCreds: ReturnType; - let loader: TenantConfigLoader; - - beforeEach(() => { - mockDb = createMockFns(); - mockCreds = createMockCredFns(); - loader = new TenantConfigLoader( - mockDb as unknown as HyperionDynamoDBClient, - {}, - mockCreds as unknown as UserCredentialStore, - ); - }); - - // -- loadTenantConfig -- - - describe("loadTenantConfig", () => { - test("builds config from DynamoDB data", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([channelLink]); - mockCreds.getCredentials.mockResolvedValue(null); - - const config = await loader.loadTenantConfig("user1"); - - expect(config).toHaveProperty( - "agents.list.0.model.primary", - "anthropic.claude-sonnet-4-20250514", - ); - expect(config).toHaveProperty("tools.allow", ["brave_search", "calculator"]); - expect(config).toHaveProperty("channels.telegram"); - expect(config).toHaveProperty("channels.telegram.enabled", true); - }); - - test("throws TenantNotFoundError when config missing", async () => { - mockDb.getTenantConfig.mockResolvedValue(null); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - await expect(loader.loadTenantConfig("nonexistent")).rejects.toThrow(TenantNotFoundError); - }); - - test("filters channel links by agentId", async () => { - const workLink: ChannelLink = { ...channelLink, agent_id: "work" }; - const mainLink: ChannelLink = { - ...channelLink, - platform_user_id: "tg11111", - agent_id: "main", - }; - - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([workLink, mainLink]); - mockCreds.getCredentials.mockResolvedValue(null); - - const config = await loader.loadTenantConfig("user1", "main"); - - // Only mainLink should be included (filtered to agent_id=main) - expect(config).toHaveProperty("channels.telegram"); - const plain = JSON.parse(JSON.stringify(config)); - const accountKeys = Object.keys(plain.channels.telegram.accounts); - expect(accountKeys).toHaveLength(1); - // The account's allowFrom should reference mainLink's platform_user_id - expect(plain.channels.telegram.accounts[accountKeys[0]].allowFrom).toEqual(["tg11111"]); - }); - - test("injects model_keys from credentials", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue({ - model_keys: { openai: "sk-test123" }, - }); - - const config = await loader.loadTenantConfig("user1"); - expect(config).toHaveProperty("models.providers.openai.apiKey", "sk-test123"); - }); - - test("injects channel_tokens into channel accounts", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([channelLink]); - mockCreds.getCredentials.mockResolvedValue({ - channel_tokens: { telegram: "bot-token-123" }, - }); - - const config = await loader.loadTenantConfig("user1"); - expect(config).toHaveProperty("channels.telegram.accounts"); - const plain = JSON.parse(JSON.stringify(config)); - expect(Object.values(plain.channels.telegram.accounts)[0]).toHaveProperty( - "botToken", - "bot-token-123", - ); - }); - }); - - // -- caching -- - - describe("caching", () => { - test("returns cached config on second call", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - const config1 = await loader.loadTenantConfig("user1"); - const config2 = await loader.loadTenantConfig("user1"); - - expect(config1).toBe(config2); // same reference - expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(1); - }); - - test("separate cache keys for different agents", async () => { - const workConfig = { ...baseTenantConfig, agent_id: "work", model: "gpt-4" }; - mockDb.getTenantConfig - .mockResolvedValueOnce(baseTenantConfig) - .mockResolvedValueOnce(workConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - const main = await loader.loadTenantConfig("user1", "main"); - const work = await loader.loadTenantConfig("user1", "work"); - - expect(main).toHaveProperty( - "agents.list.0.model.primary", - "anthropic.claude-sonnet-4-20250514", - ); - expect(work).toHaveProperty("agents.list.0.model.primary", "gpt-4"); - expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(2); - }); - - test("invalidateCache forces refetch", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - await loader.loadTenantConfig("user1"); - loader.invalidateCache("user1"); - await loader.loadTenantConfig("user1"); - - expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(2); - }); - - test("clearCache empties all entries", async () => { - mockDb.getTenantConfig.mockResolvedValue(baseTenantConfig); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - await loader.loadTenantConfig("user1"); - await loader.loadTenantConfig("user2"); - loader.clearCache(); - await loader.loadTenantConfig("user1"); - - // getTenantConfig called 3 times: user1, user2, user1 (after clear) - expect(mockDb.getTenantConfig).toHaveBeenCalledTimes(3); - }); - }); - - // -- custom_instructions / profile merging -- - - describe("agent config merging", () => { - test("applies custom_instructions to agents config", async () => { - mockDb.getTenantConfig.mockResolvedValue({ - ...baseTenantConfig, - custom_instructions: "Always respond in French", - }); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - const config = await loader.loadTenantConfig("user1"); - expect(config).toHaveProperty("agents.list.0.customInstructions", "Always respond in French"); - }); - - test("applies profile settings to agents config", async () => { - mockDb.getTenantConfig.mockResolvedValue({ - ...baseTenantConfig, - profile: { name: "TestBot", avatar: "robot" }, - }); - mockDb.getChannelLinksForUser.mockResolvedValue([]); - mockCreds.getCredentials.mockResolvedValue(null); - - const config = await loader.loadTenantConfig("user1"); - expect(config).toHaveProperty("agents.list.0.name", "TestBot"); - expect(config).toHaveProperty("agents.list.0.avatar", "robot"); - }); - }); - - // -- TenantNotFoundError -- - - describe("TenantNotFoundError", () => { - test("has correct name and tenantId", () => { - const err = new TenantNotFoundError("user999"); - expect(err.name).toBe("TenantNotFoundError"); - expect(err.tenantId).toBe("user999"); - expect(err.message).toBe("Tenant not found: user999"); - expect(err).toBeInstanceOf(Error); - }); - }); -}); diff --git a/src/hyperion/tenant-config-loader.ts b/src/hyperion/tenant-config-loader.ts deleted file mode 100644 index 8875a6af9282d..0000000000000 --- a/src/hyperion/tenant-config-loader.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { ChannelsConfig } from "../config/types.channels.js"; -import type { OpenClawConfig } from "../config/types.js"; -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { - DEFAULT_AGENT_ID, - type CachedTenantConfig, - type ChannelLink, - type TenantConfig, - type UserCredentials, -} from "./types.js"; -import type { UserCredentialStore } from "./user-credential-store.js"; - -/** Config cache TTL: 1 minute. */ -const CONFIG_CACHE_TTL_MS = 60_000; - -/** Maximum cache entries to prevent unbounded growth. */ -const CONFIG_CACHE_MAX_SIZE = 10_000; - -/** - * Loads and assembles OpenClawConfig for a specific tenant from DynamoDB. - * - * Replaces OpenClaw's filesystem-based `loadConfig()` (src/config/io.ts) with - * a multi-tenant DynamoDB-backed implementation: - * - * 1. Fetches tenant_config (profile, model, tools, skills, etc.) - * 2. Fetches all channel_config links for the user via GSI - * 3. Assembles the OpenClawConfig.channels section dynamically - * 4. Merges tenant base config with channel configs into a complete OpenClawConfig - * - * Caching: in-memory LRU with 1-minute TTL per tenant. - */ -export class TenantConfigLoader { - private readonly dbClient: HyperionDynamoDBClient; - private readonly credentialStore: UserCredentialStore | null; - private readonly cache = new Map(); - private readonly defaultConfig: Partial; - - constructor( - dbClient: HyperionDynamoDBClient, - defaultConfig?: Partial, - credentialStore?: UserCredentialStore, - ) { - this.dbClient = dbClient; - this.defaultConfig = defaultConfig ?? {}; - this.credentialStore = credentialStore ?? null; - } - - /** - * Load the full OpenClawConfig for a tenant+agent, with caching. - * [claude-infra] Multi-instance: agentId defaults to "main". - */ - async loadTenantConfig( - tenantId: string, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - const cacheKey = `${tenantId}:${agentId}`; - const cached = this.cache.get(cacheKey); - if (cached && Date.now() - cached.cachedAt < CONFIG_CACHE_TTL_MS) { - return cached.config; - } - - const config = await this.buildTenantConfig(tenantId, agentId); - - // Evict oldest entries if cache is full. - if (this.cache.size >= CONFIG_CACHE_MAX_SIZE) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey) { - this.cache.delete(oldestKey); - } - } - - this.cache.set(cacheKey, { config, cachedAt: Date.now() }); - return config; - } - - /** - * Invalidate the cached config for a tenant+agent. - * Call this when tenant config or channel links are updated. - * [claude-infra] Multi-instance: agentId defaults to "main". - */ - invalidateCache(tenantId: string, agentId: string = DEFAULT_AGENT_ID): void { - this.cache.delete(`${tenantId}:${agentId}`); - } - - /** - * Clear the entire config cache. - */ - clearCache(): void { - this.cache.clear(); - } - - /** - * Build the full OpenClawConfig for a tenant by reading DynamoDB. - */ - private async buildTenantConfig( - tenantId: string, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - // Fetch tenant config, channel links, and credentials in parallel. - // [claude-infra] Multi-instance: config + credentials keyed by (userId, agentId), - // channel links filtered to matching agent_id. - const [tenantConfig, allChannelLinks, credentials] = await Promise.all([ - this.dbClient.getTenantConfig(tenantId, agentId), - this.dbClient.getChannelLinksForUser(tenantId), - this.credentialStore?.getCredentials(tenantId, agentId) ?? Promise.resolve(null), - ]); - // Filter channel links to only those bound to this agent instance. - const channelLinks = allChannelLinks.filter( - (link) => (link.agent_id || DEFAULT_AGENT_ID) === agentId, - ); - - if (!tenantConfig) { - throw new TenantNotFoundError(tenantId); - } - - const channels = this.assembleChannelsConfig(channelLinks); - return this.mergeConfig(tenantConfig, channels, credentials); - } - - /** - * Assemble OpenClaw's ChannelsConfig from the tenant's channel links. - * - * Each channel link produces an entry under the appropriate platform key. - * Multiple links for the same platform use the multi-account pattern: - * channels.telegram.accounts[accountId] = { ...config, allowFrom: [platformUserId] } - */ - private assembleChannelsConfig(channelLinks: ChannelLink[]): ChannelsConfig { - const channels: ChannelsConfig = {}; - - for (const link of channelLinks) { - const platform = link.platform; - const accountId = link.channel_account_id || "default"; - - if (!channels[platform]) { - channels[platform] = { - enabled: true, - accounts: {}, - defaultAccount: accountId, - }; - } - - channels[platform].accounts[accountId] = this.buildAccountConfig(link); - } - - return channels; - } - - /** - * Build a per-account channel config from a channel link. - * The platform_user_id is automatically added to allowFrom - * to authorize the linked external identity. - */ - private buildAccountConfig(link: ChannelLink): Record { - const runtimeConfig = link.channel_config ?? {}; - return { - ...runtimeConfig, - // Authorize this specific external user for DM access. - allowFrom: [link.platform_user_id], - // DM policy is "open" for paired users — they've already been verified. - dmPolicy: runtimeConfig.dmPolicy ?? "open", - }; - } - - /** - * Merge tenant-level settings with the assembled channel config - * and decrypted credentials into a complete OpenClawConfig. - */ - private mergeConfig( - tenant: TenantConfig, - channels: ChannelsConfig, - credentials: UserCredentials | null, - ): OpenClawConfig { - const config: OpenClawConfig = { - ...this.defaultConfig, - channels, - }; - - // Apply tenant-level agent configuration (model, profile, custom instructions). - // OC agents are under config.agents.list — each entry is an AgentConfig. - const baseAgent = config.agents?.list?.[0] ?? { id: "default" }; - const agentPatches: Record = {}; - - if (tenant.model) { - agentPatches.model = { primary: tenant.model }; - } - if (tenant.custom_instructions) { - agentPatches.customInstructions = tenant.custom_instructions; - } - if (tenant.profile) { - Object.assign(agentPatches, tenant.profile); - } - - if (Object.keys(agentPatches).length > 0) { - config.agents = { - ...config.agents, - list: [{ ...baseAgent, ...agentPatches }], - }; - } - - // Inject per-user model provider API keys from encrypted credential store. - if (credentials?.model_keys) { - const providers = { ...config.models?.providers }; - for (const [provider, apiKey] of Object.entries(credentials.model_keys)) { - providers[provider] = { ...providers[provider], apiKey }; - } - config.models = { ...config.models, providers }; - } - - // Apply tenant-level tool permissions. - if (tenant.tools) { - config.tools = { - ...config.tools, - allow: tenant.tools, - }; - } - - // Inject per-user tool API keys from encrypted credential store. - // Each search provider resolves its key from a provider-specific path: - // brave_search → tools.web.search.apiKey (default/brave) - // gemini/grok/kimi/perplexity → tools.web.search..apiKey - if (credentials?.tool_keys) { - const web = { ...config.tools?.web }; - const search = { ...web.search } as Record; - for (const [toolName, apiKey] of Object.entries(credentials.tool_keys)) { - if (toolName === "brave_search") { - search.apiKey = apiKey; - } else if ( - toolName === "gemini" || - toolName === "grok" || - toolName === "kimi" || - toolName === "perplexity" - ) { - search[toolName] = { - ...(search[toolName] as Record | undefined), - apiKey, - }; - } - } - web.search = search; - config.tools = { ...config.tools, web }; - } - - // Apply tenant-level skill permissions. - if (tenant.skills) { - config.skills = { - ...config.skills, - allowBundled: tenant.skills, - }; - } - - // Inject per-user channel bot tokens into assembled channel accounts. - if (credentials?.channel_tokens) { - for (const [platform, token] of Object.entries(credentials.channel_tokens)) { - const platformConfig = channels[platform]; - if (platformConfig?.accounts) { - for (const account of Object.values(platformConfig.accounts)) { - account.botToken = token; - } - } - } - } - - return config; - } -} - -/** - * Thrown when a tenant_id cannot be found in the tenant_config table. - */ -export class TenantNotFoundError extends Error { - public readonly tenantId: string; - - constructor(tenantId: string) { - super(`Tenant not found: ${tenantId}`); - this.name = "TenantNotFoundError"; - this.tenantId = tenantId; - } -} diff --git a/src/hyperion/types.ts b/src/hyperion/types.ts deleted file mode 100644 index f5114e96ed71c..0000000000000 --- a/src/hyperion/types.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { OpenClawConfig } from "../config/types.js"; - -/** - * Supported external channel platforms for Hyperion. - */ -export type HyperionPlatform = "telegram" | "slack" | "whatsapp" | "discord"; - -/** - * Default agent ID for single-instance users (backwards compatible). - * [claude-infra] Multi-instance support - */ -export const DEFAULT_AGENT_ID = "main"; - -/** - * A channel link record stored in the channel_config DynamoDB table. - * Maps an external platform identity to an internal Hyperion user_id. - * - * Table schema: - * PK: platform (HyperionPlatform) - * SK: platform_user_id (string) - * GSI1PK: user_id (string) - */ -export type ChannelLink = { - /** External platform identifier (e.g., "telegram", "slack"). */ - platform: HyperionPlatform; - /** The user's identity on the external platform (e.g., Telegram user ID, Slack team+user). */ - platform_user_id: string; - /** Internal Hyperion user ID this channel is linked to. */ - user_id: string; - /** Agent instance this channel is bound to. Default: "main". [claude-infra] */ - agent_id: string; - /** ISO timestamp when the channel was paired. */ - paired_at: string; - /** Account ID within the channel (e.g., bot token alias). */ - channel_account_id: string; - /** Platform-specific channel runtime configuration. */ - channel_config: ChannelRuntimeConfig; -}; - -/** - * Platform-specific runtime configuration stored per channel link. - * Subset of OpenClaw's per-account channel config, relevant for multi-tenant operation. - */ -export type ChannelRuntimeConfig = { - /** DM policy for this channel link. */ - dmPolicy?: "pairing" | "open" | "disabled"; - /** Streaming mode for message delivery. */ - streaming?: "off" | "partial" | "block" | "progress"; - /** Max text chunk size for message splitting. */ - textChunkLimit?: number; - /** Reply threading mode. */ - replyToMode?: string; - /** Group message policy. */ - groupPolicy?: Record; - /** Bot token or Secrets Manager ARN reference. */ - credentialRef?: string; - /** Additional platform-specific settings. */ - [key: string]: unknown; -}; - -/** - * Tenant configuration stored in the tenant_config DynamoDB table. - * - * Table schema: - * PK: user_id (string), SK: agent_id (string) [claude-infra] - */ -export type TenantConfig = { - /** Internal Hyperion user ID. */ - user_id: string; - /** Agent instance ID. Default: "main". [claude-infra] */ - agent_id: string; - /** User's display name. */ - display_name?: string; - /** User's preferred model configuration. */ - model?: string; - /** User's custom instructions for the agent. */ - custom_instructions?: string; - /** User's subscription plan. */ - plan?: "free" | "pro" | "enterprise"; - /** Usage limits for the tenant. */ - limits?: { - messages_per_day?: number; - messages_per_month?: number; - }; - /** Agent profile/persona settings. */ - profile?: Record; - /** Enabled tools for this tenant. */ - tools?: string[]; - /** Enabled skills for this tenant. */ - skills?: string[]; - /** ISO timestamp when config was last updated. */ - updated_at?: string; -}; - -/** - * A pairing code record stored in the pairing_codes DynamoDB table. - * - * Table schema: - * PK: code (string) - * TTL: expires_at (number, epoch seconds) - */ -export type PairingCode = { - /** The human-friendly pairing code. */ - code: string; - /** Internal user ID that initiated the pairing. */ - user_id: string; - /** Agent instance to bind the channel to. Default: "main". [claude-infra] */ - agent_id: string; - /** Target platform for the pairing. */ - platform: HyperionPlatform; - /** ISO timestamp when the code was created. */ - created_at: string; - /** TTL attribute — epoch seconds when this code expires. */ - expires_at: number; - /** Optional metadata from the pairing request. */ - meta?: Record; -}; - -/** - * Result of resolving an inbound channel message to a tenant. - */ -export type ChannelIdentityResolution = { - /** The resolved internal user ID. */ - user_id: string; - /** The resolved agent instance ID. [claude-infra] */ - agent_id: string; - /** The channel link record. */ - channelLink: ChannelLink; - /** The assembled OpenClawConfig for this tenant+agent. */ - config: OpenClawConfig; -}; - -/** - * Per-user credentials stored encrypted in the user_credentials DynamoDB table. - * Each field is optional — users only store the keys they actually use. - * - * Values are encrypted at rest via KMS envelope encryption with - * encryption context { user_id: "" } to ensure per-tenant isolation. - */ -export type UserCredentials = { - /** Model provider API keys (e.g., openai, anthropic, google). */ - model_keys?: Record; - /** Tool API keys (e.g., brave_search, firecrawl, perplexity). */ - tool_keys?: Record; - /** Channel bot tokens (e.g., telegram, discord, slack). */ - channel_tokens?: Record; - /** Arbitrary additional credentials for custom tools/plugins. */ - custom?: Record; -}; - -/** - * Raw record stored in the user_credentials DynamoDB table. - * The credentials_blob field contains KMS-encrypted JSON of UserCredentials. - */ -export type UserCredentialsRecord = { - /** Internal Hyperion user ID (PK). */ - user_id: string; - /** Agent instance ID (SK). Default: "main". [claude-infra] */ - agent_id: string; - /** KMS-encrypted credentials blob (base64-encoded ciphertext). */ - credentials_blob: string; - /** KMS key ID used for encryption (for key rotation tracking). */ - kms_key_id: string; - /** ISO timestamp when credentials were last updated. */ - updated_at: string; -}; - -/** - * Configuration for the Hyperion DynamoDB integration. - */ -export type HyperionDynamoDBConfig = { - /** AWS region for DynamoDB. */ - region: string; - /** Tenant config table name. */ - tenantConfigTableName: string; - /** Channel config table name. */ - channelConfigTableName: string; - /** Pairing codes table name. */ - pairingCodesTableName: string; - /** User credentials table name. */ - userCredentialsTableName: string; - /** KMS key ID (ARN or alias) for credential encryption. */ - credentialsKmsKeyId: string; - /** GSI name for user_id lookups on channel_config. */ - channelConfigUserIdIndexName: string; - /** Optional DynamoDB endpoint override (for local development). */ - endpoint?: string; -}; - -/** - * Cache entry for tenant configs with TTL. - */ -export type CachedTenantConfig = { - config: OpenClawConfig; - cachedAt: number; -}; - -/** - * Cache entry for channel identity resolution with TTL. - */ -export type CachedChannelIdentity = { - channelLink: ChannelLink; - cachedAt: number; -}; diff --git a/src/hyperion/user-credential-store.ts b/src/hyperion/user-credential-store.ts deleted file mode 100644 index 03a13b25ebe0f..0000000000000 --- a/src/hyperion/user-credential-store.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { HyperionDynamoDBClient } from "./dynamodb-client.js"; -import { DEFAULT_AGENT_ID, type UserCredentials } from "./types.js"; - -/** - * Minimal KMS client interface. - * Accepts any AWS SDK v3 KMSClient-compatible implementation. - */ -export type KMSClient = { - send(command: unknown): Promise; -}; - -/** Credential cache TTL: 2 minutes (shorter than config cache for security). */ -const CREDENTIAL_CACHE_TTL_MS = 2 * 60_000; - -/** Maximum credential cache entries. */ -const CREDENTIAL_CACHE_MAX_SIZE = 10_000; - -type CachedCredentials = { - credentials: UserCredentials; - cachedAt: number; -}; - -/** - * Manages per-user API keys and credentials with KMS envelope encryption. - * - * Security model: - * - Credentials are encrypted client-side using KMS before writing to DynamoDB. - * - KMS encryption context includes { user_id } so one user's ciphertext - * cannot be decrypted with another user's context (cross-tenant isolation). - * - DynamoDB never stores plaintext credentials. - * - KMS key rotation is handled by AWS (enabled in CDK). - * - Decrypted values are cached in-memory with a short TTL to avoid - * excessive KMS calls during high-frequency request paths. - */ -export class UserCredentialStore { - private readonly dbClient: HyperionDynamoDBClient; - private readonly kmsClient: KMSClient; - private readonly kmsKeyId: string; - private readonly cache = new Map(); - - constructor(dbClient: HyperionDynamoDBClient, kmsClient: KMSClient, kmsKeyId: string) { - this.dbClient = dbClient; - this.kmsClient = kmsClient; - this.kmsKeyId = kmsKeyId; - } - - /** - * Retrieve and decrypt credentials for a user+agent. - * [claude-infra] Multi-instance: looks up agent-specific, falls back to shared. - * Returns null if the user has no stored credentials. - */ - async getCredentials( - userId: string, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - const cacheKey = `${userId}:${agentId}`; - const cached = this.cache.get(cacheKey); - if (cached && Date.now() - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS) { - return cached.credentials; - } - - const record = await this.dbClient.getUserCredentials(userId, agentId); - if (!record) { - return null; - } - - const credentials = await this.decrypt(record.credentials_blob, userId); - - if (this.cache.size >= CREDENTIAL_CACHE_MAX_SIZE) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey) { - this.cache.delete(oldestKey); - } - } - - this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); - return credentials; - } - - /** - * Encrypt and store credentials for a user+agent. - * [claude-infra] Multi-instance: stores with composite key. - */ - async putCredentials( - userId: string, - credentials: UserCredentials, - agentId: string = DEFAULT_AGENT_ID, - ): Promise { - const blob = await this.encrypt(credentials, userId); - - await this.dbClient.putUserCredentials({ - user_id: userId, - agent_id: agentId, - credentials_blob: blob, - kms_key_id: this.kmsKeyId, - updated_at: new Date().toISOString(), - }); - - const cacheKey = `${userId}:${agentId}`; - this.cache.set(cacheKey, { credentials, cachedAt: Date.now() }); - } - - /** - * Delete credentials for a user+agent. - * [claude-infra] Multi-instance: deletes specific agent's credentials. - */ - async deleteCredentials(userId: string, agentId: string = DEFAULT_AGENT_ID): Promise { - await this.dbClient.deleteUserCredentials(userId, agentId); - this.cache.delete(`${userId}:${agentId}`); - } - - /** - * Invalidate the cached credentials for a user+agent. - */ - invalidateCache(userId: string, agentId: string = DEFAULT_AGENT_ID): void { - this.cache.delete(`${userId}:${agentId}`); - } - - clearCache(): void { - this.cache.clear(); - } - - /** - * Encrypt a UserCredentials object using KMS. - * Returns base64-encoded ciphertext. - */ - private async encrypt(credentials: UserCredentials, userId: string): Promise { - const { EncryptCommand } = await import("@aws-sdk/client-kms"); - const plaintext = new TextEncoder().encode(JSON.stringify(credentials)); - - const result = await this.kmsClient.send( - new EncryptCommand({ - KeyId: this.kmsKeyId, - Plaintext: plaintext, - EncryptionContext: { user_id: userId }, - }), - ); - - const ciphertextBlob = (result as { CiphertextBlob?: Uint8Array }).CiphertextBlob; - if (!ciphertextBlob) { - throw new Error(`KMS encryption failed for user ${userId}`); - } - - return uint8ArrayToBase64(ciphertextBlob); - } - - /** - * Decrypt a base64-encoded ciphertext blob back to UserCredentials. - * The encryption context must match what was used during encryption. - */ - private async decrypt(blob: string, userId: string): Promise { - const { DecryptCommand } = await import("@aws-sdk/client-kms"); - const ciphertext = base64ToUint8Array(blob); - - const result = await this.kmsClient.send( - new DecryptCommand({ - CiphertextBlob: ciphertext, - EncryptionContext: { user_id: userId }, - }), - ); - - const plaintext = (result as { Plaintext?: Uint8Array }).Plaintext; - if (!plaintext) { - throw new Error(`KMS decryption failed for user ${userId}`); - } - - return JSON.parse(new TextDecoder().decode(plaintext)) as UserCredentials; - } -} - -function uint8ArrayToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} diff --git a/test/fixtures/extension-relative-outside-package-inventory.json b/test/fixtures/extension-relative-outside-package-inventory.json index fe51488c7066f..cb81ac51f6689 100644 --- a/test/fixtures/extension-relative-outside-package-inventory.json +++ b/test/fixtures/extension-relative-outside-package-inventory.json @@ -1 +1,34 @@ -[] +[ + { + "file": "extensions/agentcore/src/runtime.ts", + "line": 20, + "kind": "import", + "specifier": "../../hyperion/src/globals.js", + "resolvedPath": "extensions/hyperion/src/globals.js", + "reason": "imports another extension via relative path outside the extension package" + }, + { + "file": "extensions/agentcore/src/runtime.ts", + "line": 21, + "kind": "import", + "specifier": "../../hyperion/src/lib/index.js", + "resolvedPath": "extensions/hyperion/src/lib/index.js", + "reason": "imports another extension via relative path outside the extension package" + }, + { + "file": "extensions/memory-agentcore/index.ts", + "line": 2, + "kind": "import", + "specifier": "../agentcore/src/index.js", + "resolvedPath": "extensions/agentcore/src/index.js", + "reason": "imports another extension via relative path outside the extension package" + }, + { + "file": "extensions/memory-agentcore/index.ts", + "line": 3, + "kind": "import", + "specifier": "../hyperion/src/lib/index.js", + "resolvedPath": "extensions/hyperion/src/lib/index.js", + "reason": "imports another extension via relative path outside the extension package" + } +] From df3bc3a1269b52676968e0cc915f4d8394c9cdb5 Mon Sep 17 00:00:00 2001 From: Adnan Hajar Date: Wed, 25 Mar 2026 17:54:43 -0400 Subject: [PATCH 18/18] Add clarifying comment on memoryId initialization Addresses PR feedback: explain why memoryId starts as undefined (populated from SSM config JSON, stays undefined if not configured). Co-Authored-By: Claude Opus 4.6 --- extensions/agentcore/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/agentcore/src/config.ts b/extensions/agentcore/src/config.ts index e1957469ebd54..a358eaba2d44a 100644 --- a/extensions/agentcore/src/config.ts +++ b/extensions/agentcore/src/config.ts @@ -60,7 +60,7 @@ export async function loadAgentCoreConfig( } let memoryNamespacePrefix = DEFAULT_MEMORY_NAMESPACE_PREFIX; - let memoryId: string | undefined; + let memoryId: string | undefined; // Populated from SSM memory-config JSON below; stays undefined if not configured. try { const parsed = JSON.parse(memoryConfigParam ?? "{}"); if (parsed && typeof parsed.memoryNamespacePrefix === "string") {