diff --git a/config/openclaw.json5 b/config/openclaw.json5 new file mode 100644 index 0000000000000..6e67542b6765d --- /dev/null +++ b/config/openclaw.json5 @@ -0,0 +1,51 @@ +// 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 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"], + }, + + // 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, + // 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. + logging: { + consoleStyle: "json", + }, + + // Disable features not needed in headless server mode. + update: { + checkOnStart: false, + }, + discovery: { + mdns: { mode: "off" }, + }, +} 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/agentcore/index.ts b/extensions/agentcore/index.ts new file mode 100644 index 0000000000000..d59e35d5920f5 --- /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, + endpointOverride: pluginConfig.endpoint, + }, + }), + ); + }, +}; + +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..ac54a8b02a370 --- /dev/null +++ b/extensions/agentcore/src/config.test.ts @@ -0,0 +1,266 @@ +// @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"); + }); + }); + + // ── 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", () => { + 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..a358eaba2d44a --- /dev/null +++ b/extensions/agentcore/src/config.ts @@ -0,0 +1,95 @@ +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; + /** AgentCore endpoint override — applied after SSM loading (does NOT skip SSM). */ + endpointOverride?: string; +}; + +/** + * 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, + memoryId: source.localOverride.memoryId, + 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; + 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") { + memoryNamespacePrefix = parsed.memoryNamespacePrefix; + } + if (parsed && typeof parsed.memoryId === "string" && parsed.memoryId) { + memoryId = parsed.memoryId; + } + } catch { + // Fall through to default + } + + const defaultModel = defaultModelParam?.trim() || DEFAULT_MODEL; + + return { + region, + runtimeArns, + memoryNamespacePrefix, + memoryId, + defaultModel, + ...(source.endpointOverride ? { endpoint: source.endpointOverride } : {}), + }; +} + +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..30cd4ce281cd8 --- /dev/null +++ b/extensions/agentcore/src/index.ts @@ -0,0 +1,9 @@ +export { AGENTCORE_BACKEND_ID, AgentCoreRuntime } from "./runtime.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 new file mode 100644 index 0000000000000..053d734801a1a --- /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 unknown as ReturnType); + + 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 unknown 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..c1a5663ed1895 --- /dev/null +++ b/extensions/agentcore/src/runtime.ts @@ -0,0 +1,519 @@ +import crypto from "node:crypto"; +import { + BedrockAgentCoreClient, + InvokeAgentRuntimeCommand, + StopRuntimeSessionCommand, + RetrieveMemoryRecordsCommand, + BatchCreateMemoryRecordsCommand, +} 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 { 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"; + +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); + + // [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); + + // 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({ + memoryId: this.config.memoryId, + namespace, + searchCriteria: { searchQuery: query, topK: 10 }, + maxResults: 10, + }), + ); + const summaries = resp.memoryRecordSummaries; + if (!summaries || summaries.length === 0) return []; + return summaries.map((r) => ({ + content: r.content?.text ?? "", + })); + } 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 { + if (!this.config.memoryId) return; + try { + await this.client.send( + new BatchCreateMemoryRecordsCommand({ + memoryId: this.config.memoryId, + records: [ + { + requestIdentifier: crypto.randomUUID(), + namespaces: [namespace], + content: { + text: `User: ${userMessage}\nAssistant: ${agentResponse}`, + }, + timestamp: new Date(), + }, + ], + }), + ); + } 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..a1349ca21f949 --- /dev/null +++ b/extensions/agentcore/src/service.ts @@ -0,0 +1,101 @@ +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"; +import type { AgentCoreRuntimeConfig } from "./types.js"; + +type AgentCoreRuntimeLike = AcpRuntime & { + isHealthy(): boolean; + setHealthy(value: boolean): void; + 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; +}; + +/** + * 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); + _loadedConfig = 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; + _loadedConfig = null; + }, + }; +} diff --git a/extensions/agentcore/src/types.ts b/extensions/agentcore/src/types.ts new file mode 100644 index 0000000000000..5eeb1ec27dcba --- /dev/null +++ b/extensions/agentcore/src/types.ts @@ -0,0 +1,39 @@ +/** + * 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; + /** 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). */ + 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/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", + }, + }, + ], + }; +} diff --git a/extensions/hyperion/index.ts b/extensions/hyperion/index.ts new file mode 100644 index 0000000000000..8b267d7caea2e --- /dev/null +++ b/extensions/hyperion/index.ts @@ -0,0 +1,15 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +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..877c2bb900eff --- /dev/null +++ b/extensions/hyperion/src/env.ts @@ -0,0 +1,41 @@ +import type { HyperionDynamoDBConfig } from "./lib/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..82eca09d590d9 --- /dev/null +++ b/extensions/hyperion/src/globals.ts @@ -0,0 +1,38 @@ +import type { HyperionRuntime } from "./lib/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/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..68f270c4e223b --- /dev/null +++ b/extensions/hyperion/src/lib/runtime.ts @@ -0,0 +1,90 @@ +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"; +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..66fc679939e19 --- /dev/null +++ b/extensions/hyperion/src/lib/tenant-config-loader.ts @@ -0,0 +1,286 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; + +// 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, + }; + } + + const platformConfig = channels[platform]; + if (platformConfig?.accounts) { + platformConfig.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) as Array< + Record + >) { + 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..015bcb87b5cd6 --- /dev/null +++ b/extensions/hyperion/src/lib/types.ts @@ -0,0 +1,204 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; + +/** + * 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 new file mode 100644 index 0000000000000..0f4b893d353c1 --- /dev/null +++ b/extensions/hyperion/src/service.ts @@ -0,0 +1,82 @@ +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/acpx"; +import { buildDynamoConfig, resolveStage } from "./env.js"; +import { clearHyperionRuntime, setHyperionRuntime } from "./globals.js"; +import { createHyperionRuntime } from "./lib/index.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, + defaultConfig: ctx.config, + }); + + 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/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/memory-agentcore/openclaw.plugin.json b/extensions/memory-agentcore/openclaw.plugin.json new file mode 100644 index 0000000000000..a724e0e670d6c --- /dev/null +++ b/extensions/memory-agentcore/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "memory-agentcore", + "kind": "memory", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} 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/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: diff --git a/scripts/test_nova_ws.py b/scripts/test_nova_ws.py new file mode 100644 index 0000000000000..356c30f9ce6a6 --- /dev/null +++ b/scripts/test_nova_ws.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +E2E test for the Nova WebSocket channel. + +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 the DynamoDB table and APIGW Management API +""" + +import argparse +import asyncio +import json +import os +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 – 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 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", []): + 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", "") + ), + "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, + user_id: str | None = None, + source_ip: str | None = None, +) -> dict: + """Find a bot connection by connectionId, deviceId, userId, 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 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: + 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" userId: {c['userId'] or '(not set)'}") + 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): + """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"), + ) + + +async def run_test( + test_message: str, + timeout_secs: int, + connection_id: str | None, + device_id: str | None, + user_id: str | None, + source_ip: str | None, +): + """Run the E2E test.""" + print("=" * 60) + print("Nova WebSocket E2E Test") + print("=" * 60) + + # Step 1: Find the bot's connection + print("\n[1] Looking up bot connection from DynamoDB...") + 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']}") + + # 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: + # 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 on WebSocket + print(f"\n[4] Waiting for bot response on WebSocket (timeout {timeout_secs}s)...") + print("-" * 60) + + 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( + "--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)", + ) + 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( + "--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)", + ) + parser.add_argument( + "--list", "-l", + action="store_true", + help="List all active connections and exit", + ) + args = parser.parse_args() + + if args.list: + asyncio.run(run_list()) + else: + asyncio.run(run_test( + args.message, + args.timeout, + args.connection_id, + args.device_id, + args.user_id, + args.source_ip, + )) + + +if __name__ == "__main__": + main() 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"], 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" + } +]