Files
rohitg00__agentmemory/integrations/openclaw/plugin.mjs
Matt Van Horn 1a706ca2d6 fix(integrations): warn when bearer auth crosses plaintext HTTP to non-loopback (#315)
* fix(integrations): warn when bearer auth crosses plaintext HTTP to non-loopback (#275)

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* test(integrations/315): edge cases for IPv6 loopback + LAN IPs + lookalike hostnames + docs

@mvanhorn's PR #315 lands the right plaintext-bearer-auth warn-once
pattern across three runtimes (hermes/python, openclaw/mjs,
pi/typescript). Reviewed cleanly. Adding test cases + docs before
merge so the contract is locked:

Test additions (test/integration-plaintext-http.test.ts pi suite,
+4 cases, 13 total in suite):

- IPv6 loopback (http://[::1]:3111) — URL parser strips brackets,
  hostname comes back as `::1`, the LOOPBACK_HOSTS set already covers
  this. Test pins the parsing semantic so a future refactor can't
  silently break it.

- Private LAN IPs (RFC1918 ranges 192.168.x.y, 10.x.x.x) — NOT
  loopback, guard MUST warn. RFC1918 was a deliberate design choice
  (loopback set stays minimal: localhost / 127.0.0.1 / ::1). LAN
  deployment behind a tunnel still leaks the bearer over the wire.

- No-secret short-circuit (guard with secret="" or undefined) — guard
  never fires when a bearer wouldn't actually be sent. Mirrors the
  usesPlaintextBearerAuth() early-return.

- Lookalike hostname (http://localhost.evil.com:3111) — `parsed.hostname`
  returns the full `localhost.evil.com` string, which is NOT in
  LOOPBACK_HOSTS. Guard warns. This pins the exact-match (vs prefix)
  loopback semantic.

Docs additions:

- integrations/pi/README.md and integrations/hermes/README.md now
  document AGENTMEMORY_REQUIRE_HTTPS in the env-vars table — same row
  shape as AGENTMEMORY_SECRET, names the env value (=1), describes the
  warn-vs-throw semantic, and lists the loopback hostnames the guard
  recognises.

No code changes to the guard itself — it's already correct. Pre-merge
hardening only.

899 / 899 tests pass on rebased branch. Build clean.

---------

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Rohit Ghumare <ghumare64@gmail.com>
2026-05-13 15:59:50 +01:00

218 lines
7.3 KiB
JavaScript

/**
* agentmemory plugin for OpenClaw
*
* Deeper integration than raw MCP:
* - claims the plugins.slots.memory slot via api.registerMemoryCapability({ promptBuilder })
* - recalls relevant memories before the agent starts (before_agent_start hook)
* - captures completed conversation turns after the agent finishes (agent_end hook)
*
* Requires the agentmemory server on localhost:3111.
* Start it with: npx @agentmemory/agentmemory
*/
const DEFAULT_BASE_URL = "http://localhost:3111";
const DEFAULT_TIMEOUT_MS = 5000;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const configSchema = {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
base_url: { type: "string" },
token_budget: { type: "number" },
min_confidence: { type: "number" },
fallback_on_error: { type: "boolean" },
timeout_ms: { type: "number" },
},
};
function extractText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((block) => {
if (!block || typeof block !== "object") return [];
if (block.type === "text" && typeof block.text === "string") return [block.text];
return [];
})
.join("\n")
.trim();
}
function lastAssistantText(messages) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
if (message.role !== "assistant") continue;
const text = extractText(message.content);
if (text) return text;
}
return "";
}
function latestUserText(messages) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
if (message.role !== "user") continue;
const text = extractText(message.content);
if (text) return text;
}
return "";
}
function formatResults(results) {
if (!Array.isArray(results) || results.length === 0) return "";
return results
.slice(0, 5)
.map((result, index) => {
const obs = result?.observation ?? result ?? {};
const title = (obs.title || `Memory ${index + 1}`).trim();
const narrative = (obs.narrative || "").trim();
const type = (obs.type || "memory").trim();
return `- ${title} (${type})${narrative ? `: ${narrative}` : ""}`;
})
.join("\n");
}
function normalizedHostname(hostname) {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
function usesPlaintextBearerAuth(baseUrl, secret) {
if (!secret) return false;
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
} catch {
return false;
}
}
function plaintextBearerAuthMessage(baseUrl) {
return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
}
export function createPlaintextBearerAuthGuard(warn, env) {
let warned = false;
return function guardPlaintextBearerAuth(baseUrl, secret) {
if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
const message = plaintextBearerAuthMessage(baseUrl);
if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
if (!warned) {
warned = true;
warn(message);
}
};
}
function createClient(cfg, api) {
const baseUrl = String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, "");
const timeoutMs = Number(cfg.timeout_ms || DEFAULT_TIMEOUT_MS);
const fallbackOnError = cfg.fallback_on_error !== false;
const secret = process.env.AGENTMEMORY_SECRET;
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard(
(message) => api.logger.warn?.(message),
);
if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
guardPlaintextBearerAuth(baseUrl, secret);
}
async function postJson(path, payload) {
guardPlaintextBearerAuth(baseUrl, secret);
const headers = { "Content-Type": "application/json" };
if (secret) headers.Authorization = `Bearer ${secret}`;
try {
const res = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
if (fallbackOnError) return null;
const body = await res.text().catch(() => "");
throw new Error(`agentmemory ${path} failed: ${res.status} ${body}`);
}
return await res.json();
} catch (error) {
if (!fallbackOnError) throw error;
api.logger.warn?.(`agentmemory: ${String(error)}`);
return null;
}
}
return { postJson, baseUrl };
}
const plugin = {
id: "agentmemory",
name: "agentmemory",
description: "Shared cross-session memory via the local agentmemory server.",
configSchema,
register(api) {
const cfg = {
enabled: api.pluginConfig?.enabled !== false,
base_url: api.pluginConfig?.base_url || DEFAULT_BASE_URL,
token_budget: api.pluginConfig?.token_budget || 2000,
min_confidence: api.pluginConfig?.min_confidence || 0.5,
fallback_on_error: api.pluginConfig?.fallback_on_error !== false,
timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS,
};
const client = createClient(cfg, api);
if (typeof api.registerMemoryCapability === "function") {
api.registerMemoryCapability({
// OpenClaw passes { availableTools: Set<string>, citationsMode? }. We
// don't currently branch on tool availability, but accept the params
// object so the signature matches MemoryPromptSectionBuilder exactly.
promptBuilder: (_params) => [
"Long-term memory provider: agentmemory (external REST service on " +
client.baseUrl +
").",
"agentmemory recalls relevant prior observations before each turn via the before_agent_start hook and captures completed turns via agent_end.",
"Treat recalled context as background, not authoritative — prefer current workspace state and explicit user instructions when they conflict.",
],
});
}
api.on("before_agent_start", async (event) => {
if (!cfg.enabled) return;
const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : "";
if (!prompt) return;
const result = await client.postJson("/agentmemory/smart-search", {
query: prompt,
limit: 5,
});
const block = formatResults(result?.results || []);
if (!block) return;
return {
prependContext: `Relevant long-term memory from agentmemory:\n${block}`,
};
});
api.on("agent_end", async (event) => {
if (!cfg.enabled || !event?.success || !Array.isArray(event.messages)) return;
const userText = latestUserText(event.messages);
const assistantText = lastAssistantText(event.messages);
if (!userText || !assistantText) return;
const sessionId =
event.sessionId ||
event.sessionKey ||
event.runId ||
`openclaw-${Date.now()}`;
await client.postJson("/agentmemory/observe", {
hookType: "post_tool_use",
sessionId,
timestamp: new Date().toISOString(),
data: {
tool_name: "conversation",
tool_input: userText.slice(0, 1000),
tool_output: assistantText.slice(0, 4000),
},
});
});
},
};
export default plugin;