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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions config/openclaw.json5
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Hyperion Assistant Service — OC Gateway base config.
// Loaded via OC_GATEWAY_CONFIG secret in ECS container.
// Per-tenant config (model, tools, personality) is loaded from DynamoDB at runtime
// by the Hyperion plugin; this file only defines platform-level gateway settings.
{
// Gateway binds to 0.0.0.0 on port 18789 (overridden by CMD in ECS task def).
gateway: {
mode: "local",
port: 18789,
bind: "lan",
auth: {
// ALB handles TLS termination; no gateway-level auth needed.
// WAF rate-limiting + ALB security group restrict access.
mode: "none",
},
// ALB is a trusted reverse proxy — trust x-forwarded-for for client IP.
trustedProxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"],
},

// ACP runtime — route agent turns to AgentCore (registered by agentcore extension).
acp: {
enabled: true,
backend: "agentcore",
},

// Extensions loaded at startup (built into the container image via OPENCLAW_EXTENSIONS).
plugins: {
enabled: true,
},

// Logging — structured JSON for CloudWatch ingestion.
logging: {
format: "json",
},

// Disable features not needed in headless server mode.
update: {
checkOnStart: false,
},
discovery: {
mdns: { mode: "off" },
},
}
45 changes: 45 additions & 0 deletions extensions/agentcore/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/acpx";
import { createAgentCoreRuntimeService } from "./src/service.js";

type AgentCorePluginConfig = {
ssmPrefix?: string;
region?: string;
endpoint?: string;
invokeTimeoutMs?: number;
};

const plugin = {
id: "agentcore",
name: "AgentCore Runtime",
description: "ACP runtime backend powered by AWS Bedrock AgentCore.",
register(api: OpenClawPluginApi) {
const pluginConfig = (api.pluginConfig ?? {}) as AgentCorePluginConfig;

// Derive SSM prefix from HYPERION_STAGE env var if not configured.
const stage = process.env.HYPERION_STAGE;
const ssmPrefix =
pluginConfig.ssmPrefix ?? (stage ? `/hyperion/${stage}/agentcore` : undefined);

if (!ssmPrefix) {
api.logger.warn(
"AgentCore plugin: no ssmPrefix configured and HYPERION_STAGE not set. " +
"Set plugins.agentcore.ssmPrefix in config or HYPERION_STAGE env var.",
);
return;
}

const region = pluginConfig.region ?? process.env.AWS_REGION ?? "us-west-2";

api.registerService(
createAgentCoreRuntimeService({
configSource: {
ssmPrefix,
region,
localOverride: pluginConfig.endpoint ? { endpoint: pluginConfig.endpoint } : undefined,
},
}),
);
},
};

export default plugin;
48 changes: 48 additions & 0 deletions extensions/agentcore/openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
18 changes: 18 additions & 0 deletions extensions/agentcore/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
229 changes: 229 additions & 0 deletions extensions/agentcore/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// @vitest-pool threads
// ↑ vi.mock for external packages requires threads pool.

import { describe, expect, it, vi, beforeEach } from "vitest";

const mockSsmSend = vi.fn();
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient: class {
send = mockSsmSend;
},
GetParameterCommand: class {
input: unknown;
constructor(input: unknown) {
this.input = input;
}
},
}));

import { loadAgentCoreConfig } from "./config.js";

describe("loadAgentCoreConfig", () => {
beforeEach(() => {
mockSsmSend.mockReset();
});

// ── localOverride path ──────────────────────────────────────────────

describe("localOverride path", () => {
it("returns override values directly and does NOT call SSM", async () => {
const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
localOverride: {
runtimeArns: ["arn:aws:agentcore:us-west-2:123:runtime/local"],
memoryNamespacePrefix: "local_",
defaultModel: "anthropic.claude-haiku-3",
},
});

expect(config.runtimeArns).toEqual(["arn:aws:agentcore:us-west-2:123:runtime/local"]);
expect(config.memoryNamespacePrefix).toBe("local_");
expect(config.defaultModel).toBe("anthropic.claude-haiku-3");
expect(mockSsmSend).not.toHaveBeenCalled();
});

it("fills in defaults for missing fields", async () => {
const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
localOverride: {},
});

expect(config.runtimeArns).toEqual([]);
expect(config.memoryNamespacePrefix).toBe("tenant_");
expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514");
expect(config.region).toBe("us-west-2");
expect(mockSsmSend).not.toHaveBeenCalled();
});

it("preserves provided endpoint and invokeTimeoutMs", async () => {
const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
region: "eu-west-1",
localOverride: {
endpoint: "https://localhost:9999",
invokeTimeoutMs: 60_000,
},
});

expect(config.endpoint).toBe("https://localhost:9999");
expect(config.invokeTimeoutMs).toBe(60_000);
expect(config.region).toBe("eu-west-1");
});
});

// ── SSM path ────────────────────────────────────────────────────────

describe("SSM path", () => {
it("parses runtime-arns as JSON array of strings", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/runtime-arns")) {
return {
Parameter: {
Value:
'["arn:aws:agentcore:us-west-2:123:runtime/a","arn:aws:agentcore:us-west-2:123:runtime/b"]',
},
};
}
return { Parameter: { Value: null } };
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.runtimeArns).toEqual([
"arn:aws:agentcore:us-west-2:123:runtime/a",
"arn:aws:agentcore:us-west-2:123:runtime/b",
]);
});

it("parses memory-config JSON and extracts memoryNamespacePrefix", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/memory-config")) {
return {
Parameter: { Value: '{"memoryEnabled":true,"memoryNamespacePrefix":"custom_prefix_"}' },
};
}
return { Parameter: { Value: null } };
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.memoryNamespacePrefix).toBe("custom_prefix_");
});

it("uses default-model string value and trims whitespace", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/default-model")) {
return { Parameter: { Value: " anthropic.claude-haiku-3 " } };
}
return { Parameter: { Value: null } };
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.defaultModel).toBe("anthropic.claude-haiku-3");
});

it("falls back to defaults when SSM returns null for all params", async () => {
mockSsmSend.mockResolvedValue({ Parameter: { Value: null } });

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.runtimeArns).toEqual([]);
expect(config.memoryNamespacePrefix).toBe("tenant_");
expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514");
expect(config.region).toBe("us-west-2");
});

it("falls back to defaults when SSM params contain invalid JSON", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/runtime-arns")) {
return { Parameter: { Value: "not-valid-json{[" } };
}
if (name?.endsWith("/memory-config")) {
return { Parameter: { Value: "{broken" } };
}
if (name?.endsWith("/default-model")) {
return { Parameter: { Value: "" } };
}
return {};
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.runtimeArns).toEqual([]);
expect(config.memoryNamespacePrefix).toBe("tenant_");
expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514");
});

it("uses DEFAULT_REGION when region not specified in source", async () => {
mockSsmSend.mockResolvedValue({ Parameter: { Value: null } });

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.region).toBe("us-west-2");
});
});

// ── SSM error handling ──────────────────────────────────────────────

describe("SSM error handling", () => {
it("handles SSM GetParameter failures gracefully and returns defaults", async () => {
mockSsmSend.mockRejectedValue(new Error("ParameterNotFound"));

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.runtimeArns).toEqual([]);
expect(config.memoryNamespacePrefix).toBe("tenant_");
expect(config.defaultModel).toBe("anthropic.claude-sonnet-4-20250514");
expect(config.region).toBe("us-west-2");
});

it("filters out empty and non-string entries from runtimeArns", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/runtime-arns")) {
return {
Parameter: {
Value: JSON.stringify([
"arn:aws:agentcore:us-west-2:123:runtime/valid",
"",
" ",
42,
null,
"arn:aws:agentcore:us-west-2:123:runtime/also-valid",
]),
},
};
}
return { Parameter: { Value: null } };
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.runtimeArns).toEqual([
"arn:aws:agentcore:us-west-2:123:runtime/valid",
"arn:aws:agentcore:us-west-2:123:runtime/also-valid",
]);
});
});
});
Loading
Loading