From 509017be26e56d6b290ddf718669693db0308134 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 13:25:24 -0600 Subject: [PATCH 01/29] feat: add OpenClaw plugin for seamless maple-proxy integration Adds openclaw-plugin/ directory with a TypeScript OpenClaw plugin (@opensecret/maple-proxy-openclaw-plugin) that automatically downloads, manages, and runs the maple-proxy binary as a background service. Includes: - Background service: binary download/cache, process lifecycle, health checks - Plugin manifest with config schema (apiKey, port, backendUrl, debug) - maple-proxy-skill: AgentSkills-compatible skill gated on plugin config - flake.nix: adds nodejs_22 to devShell - justfile: adds plugin-install/build/lint/test/link/pack/publish commands Closes #12 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- flake.nix | 3 + justfile | 48 ++++++++ openclaw-plugin/.gitignore | 3 + openclaw-plugin/index.ts | 71 ++++++++++++ openclaw-plugin/lib/downloader.ts | 109 ++++++++++++++++++ openclaw-plugin/lib/platform.ts | 66 +++++++++++ openclaw-plugin/lib/process.ts | 105 +++++++++++++++++ openclaw-plugin/openclaw.plugin.json | 24 ++++ openclaw-plugin/package-lock.json | 48 ++++++++ openclaw-plugin/package.json | 31 +++++ .../skills/maple-proxy-skill/SKILL.md | 21 ++++ openclaw-plugin/tsconfig.json | 16 +++ 12 files changed, 545 insertions(+) create mode 100644 openclaw-plugin/.gitignore create mode 100644 openclaw-plugin/index.ts create mode 100644 openclaw-plugin/lib/downloader.ts create mode 100644 openclaw-plugin/lib/platform.ts create mode 100644 openclaw-plugin/lib/process.ts create mode 100644 openclaw-plugin/openclaw.plugin.json create mode 100644 openclaw-plugin/package-lock.json create mode 100644 openclaw-plugin/package.json create mode 100644 openclaw-plugin/skills/maple-proxy-skill/SKILL.md create mode 100644 openclaw-plugin/tsconfig.json diff --git a/flake.nix b/flake.nix index 3ca2458..af5fbe6 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,9 @@ gcc clang libclang + + # TypeScript / OpenClaw plugin + nodejs_22 # Useful tools jq diff --git a/justfile b/justfile index e4bbefc..39999bf 100644 --- a/justfile +++ b/justfile @@ -256,3 +256,51 @@ ghcr-pull tag="latest": @echo "๐Ÿ“ฅ Pulling from GitHub Container Registry..." @{{container}} pull ghcr.io/opensecretcloud/maple-proxy:{{tag}} @echo "โœ… Pulled ghcr.io/opensecretcloud/maple-proxy:{{tag}}" + +# === OpenClaw Plugin === + +# Install plugin dependencies +plugin-install: + @echo "๐Ÿ“ฆ Installing plugin dependencies..." + @cd openclaw-plugin && npm install + @echo "โœ… Plugin dependencies installed" + +# Build plugin (TypeScript -> JS) +plugin-build: + @echo "๐Ÿ”จ Building OpenClaw plugin..." + @cd openclaw-plugin && npm run build + @echo "โœ… Plugin built" + +# Lint plugin +plugin-lint: + @echo "๐Ÿ” Linting plugin..." + @cd openclaw-plugin && npm run lint + @echo "โœ… Plugin linted" + +# Test plugin +plugin-test: + @echo "๐Ÿงช Testing plugin..." + @cd openclaw-plugin && npm test + @echo "โœ… Plugin tests passed" + +# Check all (Rust + plugin) +check-all: check plugin-lint plugin-test + @echo "โœ… All checks passed (Rust + Plugin)" + +# Link plugin locally for OpenClaw development +plugin-link: + @echo "๐Ÿ”— Linking plugin to OpenClaw extensions..." + @openclaw plugins install -l ./openclaw-plugin + @echo "โœ… Plugin linked" + +# Pack plugin for npm publishing +plugin-pack: + @echo "๐Ÿ“ฆ Packing plugin for npm..." + @cd openclaw-plugin && npm pack + @echo "โœ… Plugin packed" + +# Publish plugin to npm +plugin-publish: + @echo "๐Ÿš€ Publishing plugin to npm..." + @cd openclaw-plugin && npm publish --access public + @echo "โœ… Plugin published" diff --git a/openclaw-plugin/.gitignore b/openclaw-plugin/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/openclaw-plugin/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts new file mode 100644 index 0000000..b3eacaf --- /dev/null +++ b/openclaw-plugin/index.ts @@ -0,0 +1,71 @@ +import { ensureBinary } from "./lib/downloader.js"; +import { startProxy, type RunningProxy } from "./lib/process.js"; + +interface PluginConfig { + apiKey: string; + port?: number; + backendUrl?: string; + debug?: boolean; +} + +interface PluginApi { + config: { plugins: { entries: Record } }; + logger: { info: (msg: string) => void; error: (msg: string) => void }; + registerService: (service: { + id: string; + start: () => Promise; + stop: () => Promise; + }) => void; +} + +export const id = "maple-proxy-openclaw-plugin"; +export const name = "Maple Proxy"; + +export default function register(api: PluginApi) { + let proxy: RunningProxy | null = null; + + api.registerService({ + id: "maple-proxy-service", + + async start() { + const pluginConfig = + api.config.plugins.entries["maple-proxy-openclaw-plugin"]?.config; + + if (!pluginConfig?.apiKey) { + api.logger.error( + "maple-proxy-openclaw-plugin: no apiKey configured. " + + 'Set plugins.entries["maple-proxy-openclaw-plugin"].config.apiKey in openclaw.json' + ); + return; + } + + try { + const { binaryPath, version } = await ensureBinary(api.logger); + api.logger.info(`maple-proxy binary: ${version} at ${binaryPath}`); + + proxy = await startProxy( + { + binaryPath, + apiKey: pluginConfig.apiKey, + port: pluginConfig.port, + backendUrl: pluginConfig.backendUrl, + debug: pluginConfig.debug, + }, + api.logger + ); + } catch (err) { + api.logger.error( + `maple-proxy-openclaw-plugin: failed to start: ${err instanceof Error ? err.message : err}` + ); + } + }, + + async stop() { + if (proxy) { + api.logger.info("Stopping maple-proxy..."); + proxy.kill(); + proxy = null; + } + }, + }); +} diff --git a/openclaw-plugin/lib/downloader.ts b/openclaw-plugin/lib/downloader.ts new file mode 100644 index 0000000..025ea1a --- /dev/null +++ b/openclaw-plugin/lib/downloader.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + getArtifact, + getReleaseUrl, + getChecksumUrl, + getBinaryPath, + getCacheDir, + getLatestVersion, +} from "./platform.js"; + +const execFileAsync = promisify(execFile); + +async function downloadFile(url: string, dest: string): Promise { + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + throw new Error(`Download failed: ${res.status} ${res.statusText} (${url})`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + await fsp.writeFile(dest, buffer); +} + +async function verifyChecksum(filePath: string, checksumUrl: string): Promise { + const res = await fetch(checksumUrl, { redirect: "follow" }); + if (!res.ok) { + // Checksum file may not exist for older releases; warn but don't fail + return; + } + + const expectedLine = (await res.text()).trim(); + // Format: " " or just "" + const expectedHash = expectedLine.split(/\s+/)[0]; + + const { createHash } = await import("node:crypto"); + const fileBuffer = await fsp.readFile(filePath); + const actualHash = createHash("sha256").update(fileBuffer).digest("hex"); + + if (actualHash !== expectedHash) { + await fsp.unlink(filePath); + throw new Error( + `Checksum mismatch for ${path.basename(filePath)}: ` + + `expected ${expectedHash}, got ${actualHash}` + ); + } +} + +async function extractTarGz(archivePath: string, destDir: string): Promise { + await execFileAsync("tar", ["-xzf", archivePath, "-C", destDir]); +} + +async function extractZip(archivePath: string, destDir: string): Promise { + if (process.platform === "win32") { + await execFileAsync("powershell", [ + "-Command", + `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, + ]); + } else { + await execFileAsync("unzip", ["-o", archivePath, "-d", destDir]); + } +} + +export interface DownloadResult { + binaryPath: string; + version: string; +} + +export async function ensureBinary(logger: { info: (msg: string) => void }): Promise { + const version = await getLatestVersion(); + const binaryPath = getBinaryPath(version); + + if (fs.existsSync(binaryPath)) { + logger.info(`maple-proxy ${version} already cached at ${binaryPath}`); + return { binaryPath, version }; + } + + const artifact = getArtifact(); + const cacheDir = getCacheDir(); + const versionDir = path.join(cacheDir, version); + await fsp.mkdir(versionDir, { recursive: true }); + + const ext = artifact.archiveType === "zip" ? "zip" : "tar.gz"; + const archivePath = path.join(versionDir, `${artifact.name}.${ext}`); + + logger.info(`Downloading maple-proxy ${version} for ${artifact.name}...`); + const releaseUrl = getReleaseUrl(version, artifact); + await downloadFile(releaseUrl, archivePath); + + const checksumUrl = getChecksumUrl(version, artifact); + await verifyChecksum(archivePath, checksumUrl); + + logger.info(`Extracting to ${versionDir}...`); + if (artifact.archiveType === "zip") { + await extractZip(archivePath, versionDir); + } else { + await extractTarGz(archivePath, versionDir); + } + + await fsp.unlink(archivePath); + + if (process.platform !== "win32") { + await fsp.chmod(binaryPath, 0o755); + } + + logger.info(`maple-proxy ${version} ready at ${binaryPath}`); + return { binaryPath, version }; +} diff --git a/openclaw-plugin/lib/platform.ts b/openclaw-plugin/lib/platform.ts new file mode 100644 index 0000000..faaad99 --- /dev/null +++ b/openclaw-plugin/lib/platform.ts @@ -0,0 +1,66 @@ +import os from "node:os"; +import path from "node:path"; + +const GITHUB_REPO = "OpenSecretCloud/maple-proxy"; + +export interface PlatformArtifact { + name: string; + archiveType: "tar.gz" | "zip"; +} + +export function getArtifact(): PlatformArtifact { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === "linux" && arch === "x64") { + return { name: "maple-proxy-linux-x86_64", archiveType: "tar.gz" }; + } + if (platform === "linux" && arch === "arm64") { + return { name: "maple-proxy-linux-aarch64", archiveType: "tar.gz" }; + } + if (platform === "darwin" && arch === "arm64") { + return { name: "maple-proxy-macos-aarch64", archiveType: "tar.gz" }; + } + if (platform === "win32" && arch === "x64") { + return { name: "maple-proxy-windows-x86_64", archiveType: "zip" }; + } + + throw new Error( + `Unsupported platform: ${platform}/${arch}. ` + + `Supported: linux/x64, linux/arm64, darwin/arm64, win32/x64` + ); +} + +export function getReleaseUrl(version: string, artifact: PlatformArtifact): string { + const ext = artifact.archiveType === "zip" ? "zip" : "tar.gz"; + return `https://github.com/${GITHUB_REPO}/releases/download/${version}/${artifact.name}.${ext}`; +} + +export function getChecksumUrl(version: string, artifact: PlatformArtifact): string { + const ext = artifact.archiveType === "zip" ? "zip" : "tar.gz"; + return `https://github.com/${GITHUB_REPO}/releases/download/${version}/${artifact.name}.${ext}.sha256`; +} + +export function getCacheDir(): string { + return path.join(os.homedir(), ".openclaw", "tools", "maple-proxy"); +} + +export function getBinaryName(): string { + return os.platform() === "win32" ? "maple-proxy.exe" : "maple-proxy"; +} + +export function getBinaryPath(version: string): string { + return path.join(getCacheDir(), version, getBinaryName()); +} + +export async function getLatestVersion(): Promise { + const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; + const res = await fetch(url, { + headers: { Accept: "application/vnd.github.v3+json" }, + }); + if (!res.ok) { + throw new Error(`Failed to fetch latest release: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { tag_name: string }; + return data.tag_name; +} diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts new file mode 100644 index 0000000..7054212 --- /dev/null +++ b/openclaw-plugin/lib/process.ts @@ -0,0 +1,105 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; + +export interface ProxyConfig { + binaryPath: string; + apiKey: string; + port?: number; + backendUrl?: string; + debug?: boolean; +} + +export interface RunningProxy { + process: ChildProcess; + port: number; + kill: () => void; +} + +async function findFreePort(preferred: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(preferred, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : preferred; + server.close(() => resolve(port)); + }); + server.on("error", () => { + // Preferred port busy, let OS pick one + const fallback = net.createServer(); + fallback.listen(0, "127.0.0.1", () => { + const addr = fallback.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + fallback.close(() => resolve(port)); + }); + fallback.on("error", reject); + }); + }); +} + +async function waitForHealth(port: number, timeoutMs: number = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + if (res.ok) return; + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`maple-proxy did not become healthy within ${timeoutMs}ms`); +} + +export async function startProxy( + config: ProxyConfig, + logger: { info: (msg: string) => void; error: (msg: string) => void } +): Promise { + const port = await findFreePort(config.port ?? 8080); + + const env: Record = { + ...process.env as Record, + MAPLE_HOST: "127.0.0.1", + MAPLE_PORT: String(port), + MAPLE_API_KEY: config.apiKey, + }; + + if (config.backendUrl) { + env.MAPLE_BACKEND_URL = config.backendUrl; + } + if (config.debug) { + env.MAPLE_DEBUG = "true"; + } + + const child = spawn(config.binaryPath, [], { + env, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + child.stdout?.on("data", (data: Buffer) => { + logger.info(`[maple-proxy] ${data.toString().trim()}`); + }); + + child.stderr?.on("data", (data: Buffer) => { + logger.error(`[maple-proxy] ${data.toString().trim()}`); + }); + + child.on("exit", (code) => { + if (code !== null && code !== 0) { + logger.error(`maple-proxy exited with code ${code}`); + } + }); + + await waitForHealth(port); + logger.info(`maple-proxy running on http://127.0.0.1:${port}`); + + return { + process: child, + port, + kill: () => { + if (!child.killed) { + child.kill("SIGTERM"); + } + }, + }; +} diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..9b4de80 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,24 @@ +{ + "id": "maple-proxy-openclaw-plugin", + "name": "Maple Proxy", + "description": "Run Maple TEE-backed AI models locally via maple-proxy", + "version": "0.1.0", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { "type": "string" }, + "port": { "type": "number" }, + "backendUrl": { "type": "string" }, + "debug": { "type": "boolean" } + }, + "required": ["apiKey"] + }, + "uiHints": { + "apiKey": { "label": "Maple API Key", "sensitive": true }, + "port": { "label": "Local Port", "placeholder": "8080" }, + "backendUrl": { "label": "Backend URL", "placeholder": "https://enclave.trymaple.ai" }, + "debug": { "label": "Debug Logging" } + }, + "skills": ["./skills/maple-proxy-skill"] +} diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json new file mode 100644 index 0000000..736199c --- /dev/null +++ b/openclaw-plugin/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "@opensecret/maple-proxy-openclaw-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@opensecret/maple-proxy-openclaw-plugin", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 0000000..5350af8 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,31 @@ +{ + "name": "@opensecret/maple-proxy-openclaw-plugin", + "version": "0.1.0", + "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/OpenSecretCloud/maple-proxy", + "directory": "openclaw-plugin" + }, + "openclaw": { + "extensions": ["./dist/index.js"] + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "skills", + "openclaw.plugin.json" + ], + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "echo \"no tests yet\"", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.7.0", + "@types/node": "^22.0.0" + } +} diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md new file mode 100644 index 0000000..e2c320c --- /dev/null +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -0,0 +1,21 @@ +--- +name: maple-proxy-skill +description: Use Maple TEE-backed AI models via the local maple-proxy +metadata: {"openclaw": {"requires": {"config": ["plugins.entries.maple-proxy-openclaw-plugin.enabled"]}, "primaryEnv": "MAPLE_API_KEY", "emoji": "๐Ÿ"}} +--- + +# Maple Proxy + +The maple-proxy plugin manages a local OpenAI-compatible proxy server that forwards requests to Maple's TEE (Trusted Execution Environment) backend. All AI inference runs inside secure enclaves. + +## Available Endpoints + +- `GET http://127.0.0.1:8080/v1/models` - List available models +- `POST http://127.0.0.1:8080/v1/chat/completions` - Chat completions (streaming and non-streaming) +- `GET http://127.0.0.1:8080/health` - Health check + +## Usage + +The proxy is OpenAI-compatible. Use any OpenAI client library pointed at `http://127.0.0.1:8080`. The port may differ if 8080 was busy -- check the plugin logs for the actual port. + +Authentication is handled automatically by the plugin via the configured API key. diff --git a/openclaw-plugin/tsconfig.json b/openclaw-plugin/tsconfig.json new file mode 100644 index 0000000..e9c18e7 --- /dev/null +++ b/openclaw-plugin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + }, + "include": ["index.ts", "lib/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From 02a8fd1cfd84f9b0c650e26bd0c43c36401400eb Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 13:32:25 -0600 Subject: [PATCH 02/29] feat: add version pinning, TTL cache, and old version cleanup - Add optional version field to plugin config (defaults to latest) - Cache latest-version GitHub API response for 24h to avoid rate limits - Auto-cleanup old cached binaries, keeping current + one previous - Pass pinned version from config through to ensureBinary Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 6 +- openclaw-plugin/lib/downloader.ts | 90 ++++++++++++++++++++++++++-- openclaw-plugin/openclaw.plugin.json | 6 +- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index b3eacaf..961a5b2 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -6,6 +6,7 @@ interface PluginConfig { port?: number; backendUrl?: string; debug?: boolean; + version?: string; } interface PluginApi { @@ -40,7 +41,10 @@ export default function register(api: PluginApi) { } try { - const { binaryPath, version } = await ensureBinary(api.logger); + const { binaryPath, version } = await ensureBinary( + api.logger, + pluginConfig.version + ); api.logger.info(`maple-proxy binary: ${version} at ${binaryPath}`); proxy = await startProxy( diff --git a/openclaw-plugin/lib/downloader.ts b/openclaw-plugin/lib/downloader.ts index 025ea1a..a24bdec 100644 --- a/openclaw-plugin/lib/downloader.ts +++ b/openclaw-plugin/lib/downloader.ts @@ -14,6 +14,9 @@ import { const execFileAsync = promisify(execFile); +const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const MAX_KEPT_VERSIONS = 2; // current + one previous + async function downloadFile(url: string, dest: string): Promise { const res = await fetch(url, { redirect: "follow" }); if (!res.ok) { @@ -26,12 +29,10 @@ async function downloadFile(url: string, dest: string): Promise { async function verifyChecksum(filePath: string, checksumUrl: string): Promise { const res = await fetch(checksumUrl, { redirect: "follow" }); if (!res.ok) { - // Checksum file may not exist for older releases; warn but don't fail return; } const expectedLine = (await res.text()).trim(); - // Format: " " or just "" const expectedHash = expectedLine.split(/\s+/)[0]; const { createHash } = await import("node:crypto"); @@ -62,17 +63,97 @@ async function extractZip(archivePath: string, destDir: string): Promise { } } +async function resolveVersion( + logger: { info: (msg: string) => void }, + pinnedVersion?: string +): Promise { + if (pinnedVersion) { + return pinnedVersion; + } + + const cacheDir = getCacheDir(); + const cacheFile = path.join(cacheDir, ".latest-version"); + + try { + const stat = await fsp.stat(cacheFile); + const age = Date.now() - stat.mtimeMs; + if (age < VERSION_CHECK_TTL_MS) { + const cached = (await fsp.readFile(cacheFile, "utf-8")).trim(); + if (cached) { + logger.info(`Using cached latest version: ${cached} (checked ${Math.round(age / 60000)}m ago)`); + return cached; + } + } + } catch { + // No cache file or unreadable + } + + logger.info("Checking GitHub for latest maple-proxy release..."); + const version = await getLatestVersion(); + + await fsp.mkdir(cacheDir, { recursive: true }); + await fsp.writeFile(cacheFile, version, "utf-8"); + + return version; +} + +async function cleanupOldVersions( + currentVersion: string, + logger: { info: (msg: string) => void } +): Promise { + const cacheDir = getCacheDir(); + + let entries: string[]; + try { + entries = await fsp.readdir(cacheDir); + } catch { + return; + } + + // Filter to version directories (start with "v") + const versionDirs = entries + .filter((e) => e.startsWith("v")) + .sort() + .reverse(); + + if (versionDirs.length <= MAX_KEPT_VERSIONS) { + return; + } + + // Always keep the current version; keep most recent others up to MAX_KEPT_VERSIONS + const toKeep = new Set([currentVersion]); + for (const dir of versionDirs) { + if (toKeep.size >= MAX_KEPT_VERSIONS) break; + toKeep.add(dir); + } + + for (const dir of versionDirs) { + if (toKeep.has(dir)) continue; + const dirPath = path.join(cacheDir, dir); + try { + await fsp.rm(dirPath, { recursive: true, force: true }); + logger.info(`Cleaned up old maple-proxy version: ${dir}`); + } catch { + // Best-effort cleanup + } + } +} + export interface DownloadResult { binaryPath: string; version: string; } -export async function ensureBinary(logger: { info: (msg: string) => void }): Promise { - const version = await getLatestVersion(); +export async function ensureBinary( + logger: { info: (msg: string) => void }, + pinnedVersion?: string +): Promise { + const version = await resolveVersion(logger, pinnedVersion); const binaryPath = getBinaryPath(version); if (fs.existsSync(binaryPath)) { logger.info(`maple-proxy ${version} already cached at ${binaryPath}`); + await cleanupOldVersions(version, logger); return { binaryPath, version }; } @@ -105,5 +186,6 @@ export async function ensureBinary(logger: { info: (msg: string) => void }): Pro } logger.info(`maple-proxy ${version} ready at ${binaryPath}`); + await cleanupOldVersions(version, logger); return { binaryPath, version }; } diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 9b4de80..18cda1b 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -10,7 +10,8 @@ "apiKey": { "type": "string" }, "port": { "type": "number" }, "backendUrl": { "type": "string" }, - "debug": { "type": "boolean" } + "debug": { "type": "boolean" }, + "version": { "type": "string" } }, "required": ["apiKey"] }, @@ -18,7 +19,8 @@ "apiKey": { "label": "Maple API Key", "sensitive": true }, "port": { "label": "Local Port", "placeholder": "8080" }, "backendUrl": { "label": "Backend URL", "placeholder": "https://enclave.trymaple.ai" }, - "debug": { "label": "Debug Logging" } + "debug": { "label": "Debug Logging" }, + "version": { "label": "Binary Version", "placeholder": "latest" } }, "skills": ["./skills/maple-proxy-skill"] } From 8a0cc5d33dc3f7b1c597d288f7291f726781597e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 13:54:56 -0600 Subject: [PATCH 03/29] feat: rename to maple-openclaw-plugin, add agent tool, port 8000, crash recovery Addresses PR review feedback: - Rename plugin id: maple-proxy-openclaw-plugin -> maple-openclaw-plugin - Default port changed to 8000 (vLLM default) for auto-discovery - No port fallback: errors clearly if 8000 is occupied - Add maple_proxy_status agent tool (port, version, health) - Crash recovery: auto-restart with exponential backoff (max 3 attempts) - Graceful shutdown: SIGINT first, SIGTERM after 3s - SKILL.md: documents vLLM auto-discovery, explicit provider config, port override, and status tool usage Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 83 +++++++++++- openclaw-plugin/lib/process.ts | 118 +++++++++++++----- openclaw-plugin/openclaw.plugin.json | 4 +- openclaw-plugin/package.json | 2 +- .../skills/maple-proxy-skill/SKILL.md | 66 ++++++++-- 5 files changed, 225 insertions(+), 48 deletions(-) diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index 961a5b2..4c98bbe 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -17,25 +17,92 @@ interface PluginApi { start: () => Promise; stop: () => Promise; }) => void; + registerTool: ( + tool: { + name: string; + description: string; + parameters: Record; + execute: ( + id: string, + params: Record + ) => Promise<{ content: Array<{ type: string; text: string }> }>; + }, + opts?: { optional?: boolean } + ) => void; } -export const id = "maple-proxy-openclaw-plugin"; +export const id = "maple-openclaw-plugin"; export const name = "Maple Proxy"; +const PLUGIN_CONFIG_KEY = "maple-openclaw-plugin"; + export default function register(api: PluginApi) { let proxy: RunningProxy | null = null; + api.registerTool({ + name: "maple_proxy_status", + description: + "Check the status of the local maple-proxy server. " + + "Returns the port, version, and health status.", + parameters: { + type: "object", + properties: {}, + }, + async execute() { + if (!proxy) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + running: false, + error: "maple-proxy is not running", + }), + }, + ], + }; + } + + let healthy = false; + try { + const res = await fetch( + `http://127.0.0.1:${proxy.port}/health` + ); + healthy = res.ok; + } catch { + // Not healthy + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + running: true, + healthy, + port: proxy.port, + version: proxy.version, + endpoint: `http://127.0.0.1:${proxy.port}/v1`, + modelsUrl: `http://127.0.0.1:${proxy.port}/v1/models`, + chatUrl: `http://127.0.0.1:${proxy.port}/v1/chat/completions`, + }), + }, + ], + }; + }, + }); + api.registerService({ id: "maple-proxy-service", async start() { const pluginConfig = - api.config.plugins.entries["maple-proxy-openclaw-plugin"]?.config; + api.config.plugins.entries[PLUGIN_CONFIG_KEY]?.config; if (!pluginConfig?.apiKey) { api.logger.error( - "maple-proxy-openclaw-plugin: no apiKey configured. " + - 'Set plugins.entries["maple-proxy-openclaw-plugin"].config.apiKey in openclaw.json' + `${PLUGIN_CONFIG_KEY}: no apiKey configured. ` + + `Set plugins.entries["${PLUGIN_CONFIG_KEY}"].config.apiKey in openclaw.json` ); return; } @@ -55,11 +122,17 @@ export default function register(api: PluginApi) { backendUrl: pluginConfig.backendUrl, debug: pluginConfig.debug, }, + version, api.logger ); + + api.logger.info( + `maple-proxy is OpenAI-compatible at http://127.0.0.1:${proxy.port}/v1 ` + + `-- configure as vLLM provider or use directly` + ); } catch (err) { api.logger.error( - `maple-proxy-openclaw-plugin: failed to start: ${err instanceof Error ? err.message : err}` + `${PLUGIN_CONFIG_KEY}: failed to start: ${err instanceof Error ? err.message : err}` ); } }, diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index 7054212..e26351d 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -1,6 +1,11 @@ import { spawn, type ChildProcess } from "node:child_process"; import net from "node:net"; +const DEFAULT_PORT = 8000; +const HEALTH_TIMEOUT_MS = 10000; +const MAX_RESTART_ATTEMPTS = 3; +const RESTART_BACKOFF_MS = 2000; + export interface ProxyConfig { binaryPath: string; apiKey: string; @@ -12,33 +17,23 @@ export interface ProxyConfig { export interface RunningProxy { process: ChildProcess; port: number; + version: string; kill: () => void; } -async function findFreePort(preferred: number): Promise { - return new Promise((resolve, reject) => { +function checkPortAvailable(port: number): Promise { + return new Promise((resolve) => { const server = net.createServer(); - server.listen(preferred, "127.0.0.1", () => { - const addr = server.address(); - const port = typeof addr === "object" && addr ? addr.port : preferred; - server.close(() => resolve(port)); - }); - server.on("error", () => { - // Preferred port busy, let OS pick one - const fallback = net.createServer(); - fallback.listen(0, "127.0.0.1", () => { - const addr = fallback.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - fallback.close(() => resolve(port)); - }); - fallback.on("error", reject); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); }); + server.on("error", () => resolve(false)); }); } -async function waitForHealth(port: number, timeoutMs: number = 10000): Promise { +async function waitForHealth(port: number): Promise { const start = Date.now(); - while (Date.now() - start < timeoutMs) { + while (Date.now() - start < HEALTH_TIMEOUT_MS) { try { const res = await fetch(`http://127.0.0.1:${port}/health`); if (res.ok) return; @@ -47,17 +42,16 @@ async function waitForHealth(port: number, timeoutMs: number = 10000): Promise setTimeout(r, 200)); } - throw new Error(`maple-proxy did not become healthy within ${timeoutMs}ms`); + throw new Error(`maple-proxy did not become healthy within ${HEALTH_TIMEOUT_MS}ms`); } -export async function startProxy( +function spawnProxy( config: ProxyConfig, + port: number, logger: { info: (msg: string) => void; error: (msg: string) => void } -): Promise { - const port = await findFreePort(config.port ?? 8080); - +): ChildProcess { const env: Record = { - ...process.env as Record, + ...(process.env as Record), MAPLE_HOST: "127.0.0.1", MAPLE_PORT: String(port), MAPLE_API_KEY: config.apiKey, @@ -73,7 +67,6 @@ export async function startProxy( const child = spawn(config.binaryPath, [], { env, stdio: ["ignore", "pipe", "pipe"], - detached: false, }); child.stdout?.on("data", (data: Buffer) => { @@ -84,11 +77,67 @@ export async function startProxy( logger.error(`[maple-proxy] ${data.toString().trim()}`); }); - child.on("exit", (code) => { - if (code !== null && code !== 0) { - logger.error(`maple-proxy exited with code ${code}`); - } - }); + return child; +} + +export async function startProxy( + config: ProxyConfig, + version: string, + logger: { info: (msg: string) => void; error: (msg: string) => void } +): Promise { + const port = config.port ?? DEFAULT_PORT; + + const available = await checkPortAvailable(port); + if (!available) { + throw new Error( + `Port ${port} is already in use. ` + + `Set a different port in plugins.entries["maple-openclaw-plugin"].config.port` + ); + } + + let child = spawnProxy(config, port, logger); + let stopped = false; + let restartAttempts = 0; + + const setupCrashRecovery = (proc: ChildProcess) => { + proc.on("exit", (code, signal) => { + if (stopped) return; + if (signal === "SIGINT" || signal === "SIGTERM") return; + + if (code !== null && code !== 0) { + logger.error(`maple-proxy crashed with code ${code}`); + + if (restartAttempts < MAX_RESTART_ATTEMPTS) { + restartAttempts++; + const delay = RESTART_BACKOFF_MS * restartAttempts; + logger.info( + `Restarting maple-proxy in ${delay}ms (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...` + ); + setTimeout(async () => { + if (stopped) return; + try { + child = spawnProxy(config, port, logger); + setupCrashRecovery(child); + await waitForHealth(port); + logger.info(`maple-proxy restarted on http://127.0.0.1:${port}`); + restartAttempts = 0; + } catch (err) { + logger.error( + `Failed to restart maple-proxy: ${err instanceof Error ? err.message : err}` + ); + } + }, delay); + } else { + logger.error( + `maple-proxy crashed ${MAX_RESTART_ATTEMPTS} times, giving up. ` + + `Restart the gateway to try again.` + ); + } + } + }); + }; + + setupCrashRecovery(child); await waitForHealth(port); logger.info(`maple-proxy running on http://127.0.0.1:${port}`); @@ -96,9 +145,16 @@ export async function startProxy( return { process: child, port, + version, kill: () => { + stopped = true; if (!child.killed) { - child.kill("SIGTERM"); + child.kill("SIGINT"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGTERM"); + } + }, 3000); } }, }; diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 18cda1b..b30fcfc 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "maple-proxy-openclaw-plugin", + "id": "maple-openclaw-plugin", "name": "Maple Proxy", "description": "Run Maple TEE-backed AI models locally via maple-proxy", "version": "0.1.0", @@ -17,7 +17,7 @@ }, "uiHints": { "apiKey": { "label": "Maple API Key", "sensitive": true }, - "port": { "label": "Local Port", "placeholder": "8080" }, + "port": { "label": "Local Port", "placeholder": "8000" }, "backendUrl": { "label": "Backend URL", "placeholder": "https://enclave.trymaple.ai" }, "debug": { "label": "Debug Logging" }, "version": { "label": "Binary Version", "placeholder": "latest" } diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 5350af8..0d6e8bd 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,5 +1,5 @@ { - "name": "@opensecret/maple-proxy-openclaw-plugin", + "name": "@opensecret/maple-openclaw-plugin", "version": "0.1.0", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "license": "MIT", diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index e2c320c..9c0cb1b 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -1,21 +1,69 @@ --- name: maple-proxy-skill description: Use Maple TEE-backed AI models via the local maple-proxy -metadata: {"openclaw": {"requires": {"config": ["plugins.entries.maple-proxy-openclaw-plugin.enabled"]}, "primaryEnv": "MAPLE_API_KEY", "emoji": "๐Ÿ"}} +metadata: {"openclaw": {"requires": {"config": ["plugins.entries.maple-openclaw-plugin.enabled"]}, "primaryEnv": "MAPLE_API_KEY", "emoji": "๐Ÿ"}} --- # Maple Proxy -The maple-proxy plugin manages a local OpenAI-compatible proxy server that forwards requests to Maple's TEE (Trusted Execution Environment) backend. All AI inference runs inside secure enclaves. +The maple-openclaw-plugin manages a local OpenAI-compatible proxy server that forwards requests to Maple's TEE (Trusted Execution Environment) backend. All AI inference runs inside secure enclaves. -## Available Endpoints +## Provider Setup (Recommended) -- `GET http://127.0.0.1:8080/v1/models` - List available models -- `POST http://127.0.0.1:8080/v1/chat/completions` - Chat completions (streaming and non-streaming) -- `GET http://127.0.0.1:8080/health` - Health check +maple-proxy runs on port **8000** by default -- the same as vLLM. OpenClaw can auto-discover it as a vLLM-compatible provider. To enable: -## Usage +1. Set `VLLM_API_KEY` to any value (e.g., `"maple-local"`) +2. Do **not** define an explicit `models.providers.vllm` entry +3. OpenClaw will discover models at `http://127.0.0.1:8000/v1/models` +4. Use models as `vllm/` (e.g., `vllm/llama3-3-70b`) -The proxy is OpenAI-compatible. Use any OpenAI client library pointed at `http://127.0.0.1:8080`. The port may differ if 8080 was busy -- check the plugin logs for the actual port. +Or configure explicitly under `models.providers`: -Authentication is handled automatically by the plugin via the configured API key. +```json +{ + "models": { + "providers": { + "maple": { + "baseUrl": "http://127.0.0.1:8000/v1", + "apiKey": "maple-local", + "api": "openai-completions", + "models": [ + { "id": "llama3-3-70b", "name": "Llama 3.3 70B" } + ] + } + } + } +} +``` + +## Status Tool + +Use the `maple_proxy_status` tool to check if the proxy is running, which port it is on, and its health status. + +## Direct API Access + +- `GET http://127.0.0.1:8000/v1/models` - List available models +- `POST http://127.0.0.1:8000/v1/chat/completions` - Chat completions (streaming and non-streaming) +- `GET http://127.0.0.1:8000/health` - Health check + +## Port Override + +The default port is 8000. If something else uses port 8000, override it in plugin config: + +```json +{ + "plugins": { + "entries": { + "maple-openclaw-plugin": { + "config": { "port": 8200 } + } + } + } +} +``` + +If you change the port, update your `models.providers` base URL to match. + +## Authentication + +Authentication is handled automatically by the plugin via the configured API key. No per-request auth headers are needed from the agent. From f2b112b823cb9e79a7177f0f0206c87b592d248c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 14:04:03 -0600 Subject: [PATCH 04/29] fix: address PR review - spawn error race, duplicate start guard, tests - Race waitForHealth against child spawn errors so missing/broken binaries fail fast instead of waiting the full 10s health timeout. Kill and clean up child process on failure. - Guard against duplicate start(): kill existing proxy before spawning a new one to prevent leaked processes on config reload. - Log warning when checksum file is unavailable instead of silently skipping. - Fix PowerShell injection: pass archive/dest paths as separate arguments. - Extract compareVersionsDesc for semver-aware version sorting (fixes v0.9.0 vs v0.10.0 lexicographic bug). - Add 9 unit tests for version sort covering edge cases. - Distinguish EADDRINUSE from other port errors in checkPortAvailable. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 6 +++ openclaw-plugin/lib/downloader.test.ts | 65 ++++++++++++++++++++++++++ openclaw-plugin/lib/downloader.ts | 33 ++++++++++--- openclaw-plugin/lib/process.ts | 38 +++++++++++++-- openclaw-plugin/package.json | 2 +- openclaw-plugin/tsconfig.json | 2 +- 6 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 openclaw-plugin/lib/downloader.test.ts diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index 4c98bbe..cfd896f 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -96,6 +96,12 @@ export default function register(api: PluginApi) { id: "maple-proxy-service", async start() { + if (proxy) { + api.logger.info("Stopping existing maple-proxy before restart..."); + proxy.kill(); + proxy = null; + } + const pluginConfig = api.config.plugins.entries[PLUGIN_CONFIG_KEY]?.config; diff --git a/openclaw-plugin/lib/downloader.test.ts b/openclaw-plugin/lib/downloader.test.ts new file mode 100644 index 0000000..c8527f9 --- /dev/null +++ b/openclaw-plugin/lib/downloader.test.ts @@ -0,0 +1,65 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { compareVersionsDesc } from "./downloader.js"; + +describe("compareVersionsDesc", () => { + it("sorts simple versions in descending order", () => { + const input = ["v0.1.0", "v0.2.0", "v0.1.5"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v0.2.0", "v0.1.5", "v0.1.0"]); + }); + + it("handles v0.9.0 vs v0.10.0 correctly (not lexicographic)", () => { + const input = ["v0.9.0", "v0.10.0", "v0.2.0"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v0.10.0", "v0.9.0", "v0.2.0"]); + }); + + it("handles major version differences", () => { + const input = ["v1.0.0", "v2.0.0", "v0.9.0"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v2.0.0", "v1.0.0", "v0.9.0"]); + }); + + it("handles patch version differences", () => { + const input = ["v0.1.1", "v0.1.3", "v0.1.2"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v0.1.3", "v0.1.2", "v0.1.1"]); + }); + + it("handles double-digit version components", () => { + const input = ["v1.2.3", "v1.12.0", "v1.2.30"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v1.12.0", "v1.2.30", "v1.2.3"]); + }); + + it("keeps equal versions stable", () => { + const input = ["v0.1.6", "v0.1.6"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v0.1.6", "v0.1.6"]); + }); + + it("handles single element", () => { + const input = ["v0.1.0"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, ["v0.1.0"]); + }); + + it("handles empty array", () => { + const input: string[] = []; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, []); + }); + + it("handles realistic release sequence", () => { + const input = ["v0.1.0", "v0.1.6", "v0.1.5", "v0.2.0", "v0.1.10"]; + const result = [...input].sort(compareVersionsDesc); + assert.deepStrictEqual(result, [ + "v0.2.0", + "v0.1.10", + "v0.1.6", + "v0.1.5", + "v0.1.0", + ]); + }); +}); diff --git a/openclaw-plugin/lib/downloader.ts b/openclaw-plugin/lib/downloader.ts index a24bdec..bf946fd 100644 --- a/openclaw-plugin/lib/downloader.ts +++ b/openclaw-plugin/lib/downloader.ts @@ -17,6 +17,16 @@ const execFileAsync = promisify(execFile); const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const MAX_KEPT_VERSIONS = 2; // current + one previous +function parseVer(v: string): number[] { + return v.replace(/^v/, "").split(".").map(Number); +} + +export function compareVersionsDesc(a: string, b: string): number { + const [aMaj = 0, aMin = 0, aPat = 0] = parseVer(a); + const [bMaj = 0, bMin = 0, bPat = 0] = parseVer(b); + return bMaj - aMaj || bMin - aMin || bPat - aPat; +} + async function downloadFile(url: string, dest: string): Promise { const res = await fetch(url, { redirect: "follow" }); if (!res.ok) { @@ -26,9 +36,16 @@ async function downloadFile(url: string, dest: string): Promise { await fsp.writeFile(dest, buffer); } -async function verifyChecksum(filePath: string, checksumUrl: string): Promise { +async function verifyChecksum( + filePath: string, + checksumUrl: string, + logger: { info: (msg: string) => void } +): Promise { const res = await fetch(checksumUrl, { redirect: "follow" }); if (!res.ok) { + logger.info( + `Warning: checksum file not available (${res.status}), skipping verification for ${path.basename(filePath)}` + ); return; } @@ -55,8 +72,14 @@ async function extractTarGz(archivePath: string, destDir: string): Promise async function extractZip(archivePath: string, destDir: string): Promise { if (process.platform === "win32") { await execFileAsync("powershell", [ + "-NoProfile", "-Command", - `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, + "Expand-Archive", + "-Path", + archivePath, + "-DestinationPath", + destDir, + "-Force", ]); } else { await execFileAsync("unzip", ["-o", archivePath, "-d", destDir]); @@ -110,11 +133,9 @@ async function cleanupOldVersions( return; } - // Filter to version directories (start with "v") const versionDirs = entries .filter((e) => e.startsWith("v")) - .sort() - .reverse(); + .sort(compareVersionsDesc); if (versionDirs.length <= MAX_KEPT_VERSIONS) { return; @@ -170,7 +191,7 @@ export async function ensureBinary( await downloadFile(releaseUrl, archivePath); const checksumUrl = getChecksumUrl(version, artifact); - await verifyChecksum(archivePath, checksumUrl); + await verifyChecksum(archivePath, checksumUrl, logger); logger.info(`Extracting to ${versionDir}...`); if (artifact.archiveType === "zip") { diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index e26351d..a609758 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -22,12 +22,18 @@ export interface RunningProxy { } function checkPortAvailable(port: number): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(port, "127.0.0.1", () => { server.close(() => resolve(true)); }); - server.on("error", () => resolve(false)); + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve(false); + } else { + reject(err); + } + }); }); } @@ -139,7 +145,33 @@ export async function startProxy( setupCrashRecovery(child); - await waitForHealth(port); + // Race health check against spawn errors so we fail fast if the binary + // is missing, not executable, or crashes immediately on startup. + try { + await Promise.race([ + waitForHealth(port), + new Promise((_, reject) => { + child.on("error", (err) => { + reject(new Error(`maple-proxy failed to spawn: ${err.message}`)); + }); + child.on("exit", (code, signal) => { + if (code !== null && code !== 0) { + reject(new Error(`maple-proxy exited immediately with code ${code}`)); + } else if (signal) { + reject(new Error(`maple-proxy killed by signal ${signal} during startup`)); + } + }); + }), + ]); + } catch (err) { + // Clean up the child process if it's still around + if (!child.killed) { + child.kill("SIGKILL"); + } + stopped = true; + throw err; + } + logger.info(`maple-proxy running on http://127.0.0.1:${port}`); return { diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 0d6e8bd..7d19a60 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -21,7 +21,7 @@ "scripts": { "build": "tsc", "lint": "tsc --noEmit", - "test": "echo \"no tests yet\"", + "test": "tsc && node --test dist/lib/downloader.test.js", "prepublishOnly": "npm run build" }, "devDependencies": { diff --git a/openclaw-plugin/tsconfig.json b/openclaw-plugin/tsconfig.json index e9c18e7..0dc3f72 100644 --- a/openclaw-plugin/tsconfig.json +++ b/openclaw-plugin/tsconfig.json @@ -11,6 +11,6 @@ "declaration": true, "sourceMap": true }, - "include": ["index.ts", "lib/**/*.ts"], + "include": ["index.ts", "lib/**/*.ts", "lib/**/*.test.ts"], "exclude": ["node_modules", "dist"] } From 07f853d83d00998b26cd4c839bd99514b21b9c21 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 14:10:30 -0600 Subject: [PATCH 05/29] fix: SIGTERM escalation, signal crash recovery, checksum security - Fix SIGTERM escalation dead code: track actual exit state instead of relying on child.killed (which is true immediately after kill() call). Now SIGTERM correctly fires after 3s if SIGINT did not terminate the process. - Fix crash recovery for signal-killed processes: handle SIGSEGV, SIGABRT, SIGBUS etc. Previously code === null (signal death) skipped the entire recovery block silently. Now any non-intentional crash triggers restart. - Fix checksum verification: only skip on 404 (file not found). Other HTTP errors (403 rate limit, 500 server error) now throw instead of silently using an unverified binary. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/downloader.ts | 12 +++++++++--- openclaw-plugin/lib/process.ts | 32 +++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/openclaw-plugin/lib/downloader.ts b/openclaw-plugin/lib/downloader.ts index bf946fd..107f026 100644 --- a/openclaw-plugin/lib/downloader.ts +++ b/openclaw-plugin/lib/downloader.ts @@ -43,10 +43,16 @@ async function verifyChecksum( ): Promise { const res = await fetch(checksumUrl, { redirect: "follow" }); if (!res.ok) { - logger.info( - `Warning: checksum file not available (${res.status}), skipping verification for ${path.basename(filePath)}` + if (res.status === 404) { + logger.info( + `Warning: checksum file not found (404), skipping verification for ${path.basename(filePath)}` + ); + return; + } + throw new Error( + `Failed to fetch checksum for ${path.basename(filePath)}: ${res.status} ${res.statusText}. ` + + `This may indicate GitHub rate limiting or a server error.` ); - return; } const expectedLine = (await res.text()).trim(); diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index a609758..f338ec2 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -110,8 +110,16 @@ export async function startProxy( if (stopped) return; if (signal === "SIGINT" || signal === "SIGTERM") return; - if (code !== null && code !== 0) { - logger.error(`maple-proxy crashed with code ${code}`); + const crashed = + (code !== null && code !== 0) || + (code === null && signal !== null); + + if (crashed) { + const reason = + code !== null + ? `exit code ${code}` + : `signal ${signal}`; + logger.error(`maple-proxy crashed (${reason})`); if (restartAttempts < MAX_RESTART_ATTEMPTS) { restartAttempts++; @@ -174,20 +182,24 @@ export async function startProxy( logger.info(`maple-proxy running on http://127.0.0.1:${port}`); + let exited = false; + child.on("exit", () => { + exited = true; + }); + return { process: child, port, version, kill: () => { stopped = true; - if (!child.killed) { - child.kill("SIGINT"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGTERM"); - } - }, 3000); - } + if (exited) return; + child.kill("SIGINT"); + setTimeout(() => { + if (!exited) { + child.kill("SIGTERM"); + } + }, 3000); }, }; } From 06cb45028e593030e155b6cfbd82b4fb9df5188c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 20:08:42 -0600 Subject: [PATCH 06/29] fix: track exited flag across crash recovery restarts Move exited flag and its exit listener to the same scope as crash recovery. Reset exited=false and attach a new listener after each respawn so kill() correctly targets the current child process, not a stale reference from a previous crash. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index f338ec2..d007b3c 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -103,8 +103,16 @@ export async function startProxy( let child = spawnProxy(config, port, logger); let stopped = false; + let exited = false; let restartAttempts = 0; + const trackExit = (proc: ChildProcess) => { + proc.on("exit", () => { + exited = true; + }); + }; + trackExit(child); + const setupCrashRecovery = (proc: ChildProcess) => { proc.on("exit", (code, signal) => { if (stopped) return; @@ -131,6 +139,8 @@ export async function startProxy( if (stopped) return; try { child = spawnProxy(config, port, logger); + exited = false; + trackExit(child); setupCrashRecovery(child); await waitForHealth(port); logger.info(`maple-proxy restarted on http://127.0.0.1:${port}`); @@ -182,11 +192,6 @@ export async function startProxy( logger.info(`maple-proxy running on http://127.0.0.1:${port}`); - let exited = false; - child.on("exit", () => { - exited = true; - }); - return { process: child, port, From 1ef92594f10be9b269927bb29230330a59176874 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 20:22:12 -0600 Subject: [PATCH 07/29] fix: stale process reference after crash recovery, SIGKILL escalation - Make RunningProxy.process a getter so it always returns the current child process, not a stale reference from before crash recovery. - Escalate from SIGINT to SIGKILL (not SIGTERM) after 3s timeout. SIGTERM is equally catchable/ignorable as SIGINT; SIGKILL guarantees termination and prevents orphaned processes on shutdown. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index d007b3c..1b8fa1a 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -15,7 +15,7 @@ export interface ProxyConfig { } export interface RunningProxy { - process: ChildProcess; + readonly process: ChildProcess; port: number; version: string; kill: () => void; @@ -193,7 +193,9 @@ export async function startProxy( logger.info(`maple-proxy running on http://127.0.0.1:${port}`); return { - process: child, + get process() { + return child; + }, port, version, kill: () => { @@ -202,7 +204,7 @@ export async function startProxy( child.kill("SIGINT"); setTimeout(() => { if (!exited) { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } }, 3000); }, From 4abcc8a0f8a2fd9cb6c574768751a26197862a1d Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 20:31:58 -0600 Subject: [PATCH 08/29] fix: add concurrent start() guard to prevent download/extraction races Add a starting flag with try/finally so concurrent start() calls (e.g. during rapid config reloads) do not race on ensureBinary, preventing duplicate downloads and file corruption during extraction. Also flatten the nested try blocks for clarity. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index cfd896f..5ecb256 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -38,6 +38,7 @@ const PLUGIN_CONFIG_KEY = "maple-openclaw-plugin"; export default function register(api: PluginApi) { let proxy: RunningProxy | null = null; + let starting = false; api.registerTool({ name: "maple_proxy_status", @@ -96,24 +97,30 @@ export default function register(api: PluginApi) { id: "maple-proxy-service", async start() { - if (proxy) { - api.logger.info("Stopping existing maple-proxy before restart..."); - proxy.kill(); - proxy = null; - } - - const pluginConfig = - api.config.plugins.entries[PLUGIN_CONFIG_KEY]?.config; - - if (!pluginConfig?.apiKey) { - api.logger.error( - `${PLUGIN_CONFIG_KEY}: no apiKey configured. ` + - `Set plugins.entries["${PLUGIN_CONFIG_KEY}"].config.apiKey in openclaw.json` - ); + if (starting) { + api.logger.info("maple-proxy start already in progress, skipping"); return; } + starting = true; try { + if (proxy) { + api.logger.info("Stopping existing maple-proxy before restart..."); + proxy.kill(); + proxy = null; + } + + const pluginConfig = + api.config.plugins.entries[PLUGIN_CONFIG_KEY]?.config; + + if (!pluginConfig?.apiKey) { + api.logger.error( + `${PLUGIN_CONFIG_KEY}: no apiKey configured. ` + + `Set plugins.entries["${PLUGIN_CONFIG_KEY}"].config.apiKey in openclaw.json` + ); + return; + } + const { binaryPath, version } = await ensureBinary( api.logger, pluginConfig.version @@ -140,6 +147,8 @@ export default function register(api: PluginApi) { api.logger.error( `${PLUGIN_CONFIG_KEY}: failed to start: ${err instanceof Error ? err.message : err}` ); + } finally { + starting = false; } }, From c84b8be9f2da2a462010b3545da212fed90fe74d Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 20:44:41 -0600 Subject: [PATCH 09/29] fix: regenerate package-lock.json to match renamed package Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json index 736199c..fa0b90d 100644 --- a/openclaw-plugin/package-lock.json +++ b/openclaw-plugin/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@opensecret/maple-proxy-openclaw-plugin", + "name": "@opensecret/maple-openclaw-plugin", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@opensecret/maple-proxy-openclaw-plugin", + "name": "@opensecret/maple-openclaw-plugin", "version": "0.1.0", "license": "MIT", "devDependencies": { From a0743eefe99850eaf31671510e6b5104cffdadc7 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sun, 15 Feb 2026 20:44:57 -0600 Subject: [PATCH 10/29] fix: kill orphaned child process on crash-recovery restart failure When crash recovery spawns a new child but waitForHealth times out, the child was left running. Now SIGKILL it in the catch block, matching the initial startup error handling path. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index 1b8fa1a..e002944 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -149,6 +149,9 @@ export async function startProxy( logger.error( `Failed to restart maple-proxy: ${err instanceof Error ? err.message : err}` ); + if (!child.killed) { + child.kill("SIGKILL"); + } } }, delay); } else { From 2731a1ba07a3f49bd4d0a7637c340fab0af5cf70 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 13:45:39 -0600 Subject: [PATCH 11/29] docs: add embeddings endpoint and memory search config to SKILL.md maple-proxy now serves /v1/embeddings with nomic-embed-text. Document how to configure OpenClaw memorySearch to use maple-proxy as the embedding provider, keeping all vector operations inside the TEE. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../skills/maple-proxy-skill/SKILL.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index 9c0cb1b..2ab2ff5 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -40,10 +40,40 @@ Or configure explicitly under `models.providers`: Use the `maple_proxy_status` tool to check if the proxy is running, which port it is on, and its health status. +## Embeddings & Memory Search + +maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-embed-text` model. You can use this for OpenClaw's memory search so that embeddings are generated inside the TEE -- no cloud embedding provider needed. + +To configure memory search with maple-proxy embeddings, add this to your `openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "memorySearch": { + "provider": "openai", + "model": "nomic-embed-text", + "remote": { + "baseUrl": "http://127.0.0.1:8000/v1/", + "apiKey": "maple-local" + } + } + } + } +} +``` + +Notes: +- The `apiKey` value can be anything (e.g., `"maple-local"`) since maple-proxy uses the plugin-configured API key for backend auth +- If you changed the plugin port, update the `baseUrl` accordingly +- This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings +- Compatible with OpenClaw's hybrid search (BM25 + vector), session memory indexing, and embedding cache + ## Direct API Access - `GET http://127.0.0.1:8000/v1/models` - List available models - `POST http://127.0.0.1:8000/v1/chat/completions` - Chat completions (streaming and non-streaming) +- `POST http://127.0.0.1:8000/v1/embeddings` - Generate embeddings (model: `nomic-embed-text`) - `GET http://127.0.0.1:8000/health` - Health check ## Port Override From 2516ca37653dde64a156016f1ebdb0148d1b687c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 13:53:13 -0600 Subject: [PATCH 12/29] fix: correct apiKey guidance in memory search config The apiKey in memorySearch.remote is forwarded as a Bearer token to the TEE backend, so it must be the real Maple API key, not an arbitrary placeholder. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/skills/maple-proxy-skill/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index 2ab2ff5..052b843 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -55,7 +55,7 @@ To configure memory search with maple-proxy embeddings, add this to your `opencl "model": "nomic-embed-text", "remote": { "baseUrl": "http://127.0.0.1:8000/v1/", - "apiKey": "maple-local" + "apiKey": "YOUR_MAPLE_API_KEY" } } } @@ -64,7 +64,7 @@ To configure memory search with maple-proxy embeddings, add this to your `opencl ``` Notes: -- The `apiKey` value can be anything (e.g., `"maple-local"`) since maple-proxy uses the plugin-configured API key for backend auth +- Use the same Maple API key you configured in the plugin config -- maple-proxy forwards the `Authorization: Bearer` header to the TEE backend for authentication - If you changed the plugin port, update the `baseUrl` accordingly - This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings - Compatible with OpenClaw's hybrid search (BM25 + vector), session memory indexing, and embedding cache From ea3a44f6cc4245d0e69d483593994083f74be4a2 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 14:57:00 -0600 Subject: [PATCH 13/29] fix: align plugin packaging with OpenClaw conventions - Add "type": "module" (required by all OpenClaw extensions) - Point openclaw.extensions at ./index.ts instead of ./dist/index.js (OpenClaw uses jiti to load TypeScript directly at runtime) - Ship .ts source files instead of dist/ in the npm package - Add openclaw peerDependency (>=2026.1.0, optional) - Remove prepublishOnly build step (no longer needed) - Remove main/types fields (jiti handles resolution) Based on conventions from all official OpenClaw extensions. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package-lock.json | 8 ++++++++ openclaw-plugin/package.json | 19 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json index fa0b90d..d099faf 100644 --- a/openclaw-plugin/package-lock.json +++ b/openclaw-plugin/package-lock.json @@ -11,6 +11,14 @@ "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" + }, + "peerDependencies": { + "openclaw": ">=2026.1.0" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } } }, "node_modules/@types/node": { diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 7d19a60..7a54cf4 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -2,6 +2,7 @@ "name": "@opensecret/maple-openclaw-plugin", "version": "0.1.0", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", + "type": "module", "license": "MIT", "repository": { "type": "git", @@ -9,20 +10,26 @@ "directory": "openclaw-plugin" }, "openclaw": { - "extensions": ["./dist/index.js"] + "extensions": ["./index.ts"] }, - "main": "dist/index.js", - "types": "dist/index.d.ts", "files": [ - "dist", + "index.ts", + "lib", "skills", "openclaw.plugin.json" ], "scripts": { "build": "tsc", "lint": "tsc --noEmit", - "test": "tsc && node --test dist/lib/downloader.test.js", - "prepublishOnly": "npm run build" + "test": "tsc && node --test dist/lib/downloader.test.js" + }, + "peerDependencies": { + "openclaw": ">=2026.1.0" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } }, "devDependencies": { "typescript": "^5.7.0", From eaba2f777a710108dfa01d29885a0eca5a5b366e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 15:25:58 -0600 Subject: [PATCH 14/29] fix: rewrite SKILL.md from real usage, change default port to 8787 Based on real-world OpenClaw setup feedback: - Lead with explicit maple provider config (not vLLM auto-discovery) - Change default port from 8000 to 8787 to avoid vLLM conflicts - Document the model allowlist step (agents.defaults.models) - Document subagent usage with maple/ prefix - Fix auth docs: apiKey must be real Maple key everywhere - Update all port references in examples and manifests Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 2 +- openclaw-plugin/openclaw.plugin.json | 2 +- openclaw-plugin/package.json | 2 +- .../skills/maple-proxy-skill/SKILL.md | 88 ++++++++++++------- 4 files changed, 60 insertions(+), 34 deletions(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index e002944..2a90b51 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import net from "node:net"; -const DEFAULT_PORT = 8000; +const DEFAULT_PORT = 8787; const HEALTH_TIMEOUT_MS = 10000; const MAX_RESTART_ATTEMPTS = 3; const RESTART_BACKOFF_MS = 2000; diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index b30fcfc..619ae05 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -17,7 +17,7 @@ }, "uiHints": { "apiKey": { "label": "Maple API Key", "sensitive": true }, - "port": { "label": "Local Port", "placeholder": "8000" }, + "port": { "label": "Local Port", "placeholder": "8787" }, "backendUrl": { "label": "Backend URL", "placeholder": "https://enclave.trymaple.ai" }, "debug": { "label": "Debug Logging" }, "version": { "label": "Binary Version", "placeholder": "latest" } diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 7a54cf4..041dd53 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0", + "version": "0.1.0-beta.1", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT", diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index 052b843..d14f7c2 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -8,27 +8,23 @@ metadata: {"openclaw": {"requires": {"config": ["plugins.entries.maple-openclaw- The maple-openclaw-plugin manages a local OpenAI-compatible proxy server that forwards requests to Maple's TEE (Trusted Execution Environment) backend. All AI inference runs inside secure enclaves. -## Provider Setup (Recommended) +## Setup -maple-proxy runs on port **8000** by default -- the same as vLLM. OpenClaw can auto-discover it as a vLLM-compatible provider. To enable: +### 1. Add the Maple provider -1. Set `VLLM_API_KEY` to any value (e.g., `"maple-local"`) -2. Do **not** define an explicit `models.providers.vllm` entry -3. OpenClaw will discover models at `http://127.0.0.1:8000/v1/models` -4. Use models as `vllm/` (e.g., `vllm/llama3-3-70b`) - -Or configure explicitly under `models.providers`: +Add a `maple` provider to your `openclaw.json` with your Maple API key and the models you want to use. maple-proxy runs on port **8787** by default. ```json { "models": { "providers": { "maple": { - "baseUrl": "http://127.0.0.1:8000/v1", - "apiKey": "maple-local", + "baseUrl": "http://127.0.0.1:8787/v1", + "apiKey": "YOUR_MAPLE_API_KEY", "api": "openai-completions", "models": [ - { "id": "llama3-3-70b", "name": "Llama 3.3 70B" } + { "id": "kimi-k2-5", "name": "Kimi K2.5 (recommended)" }, + { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" } ] } } @@ -36,16 +32,54 @@ Or configure explicitly under `models.providers`: } ``` +Use the same Maple API key you configured in the plugin config -- maple-proxy forwards the `Authorization: Bearer` header to the TEE backend for authentication. + +To discover available models, use the `maple_proxy_status` tool or call `GET http://127.0.0.1:8787/v1/models` directly. + +### 2. Add models to the allowlist + +If you have an `agents.defaults.models` section in your config, you must add the maple models you want to use. If you don't have this section at all, skip this step -- all models are allowed by default. + +Add each model you want to use as `maple/`. Check available models via `GET http://127.0.0.1:8787/v1/models` or the `maple_proxy_status` tool. + +```json +{ + "agents": { + "defaults": { + "models": { + "maple/kimi-k2-5": {}, + "maple/llama-3.3-70b": {} + } + } + } +} +``` + +### 3. Restart the gateway + +Restart the OpenClaw gateway to pick up the new provider and model config. + +## Using Maple Models + +Use maple models by prefixing with `maple/`: + +- `maple/kimi-k2-5` (recommended) +- `maple/llama-3.3-70b` + +To spawn a subagent on a Maple model: + +``` +Use sessions_spawn with model: "maple/kimi-k2-5" to run tasks on Maple TEE models. +``` + ## Status Tool -Use the `maple_proxy_status` tool to check if the proxy is running, which port it is on, and its health status. +Use the `maple_proxy_status` tool to check if the proxy is running, which port it is on, its health status, and the available models endpoint. ## Embeddings & Memory Search maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-embed-text` model. You can use this for OpenClaw's memory search so that embeddings are generated inside the TEE -- no cloud embedding provider needed. -To configure memory search with maple-proxy embeddings, add this to your `openclaw.json`: - ```json { "agents": { @@ -54,7 +88,7 @@ To configure memory search with maple-proxy embeddings, add this to your `opencl "provider": "openai", "model": "nomic-embed-text", "remote": { - "baseUrl": "http://127.0.0.1:8000/v1/", + "baseUrl": "http://127.0.0.1:8787/v1/", "apiKey": "YOUR_MAPLE_API_KEY" } } @@ -63,37 +97,29 @@ To configure memory search with maple-proxy embeddings, add this to your `opencl } ``` -Notes: -- Use the same Maple API key you configured in the plugin config -- maple-proxy forwards the `Authorization: Bearer` header to the TEE backend for authentication -- If you changed the plugin port, update the `baseUrl` accordingly -- This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings -- Compatible with OpenClaw's hybrid search (BM25 + vector), session memory indexing, and embedding cache +Use the same Maple API key here. This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings. Compatible with OpenClaw's hybrid search (BM25 + vector), session memory indexing, and embedding cache. ## Direct API Access -- `GET http://127.0.0.1:8000/v1/models` - List available models -- `POST http://127.0.0.1:8000/v1/chat/completions` - Chat completions (streaming and non-streaming) -- `POST http://127.0.0.1:8000/v1/embeddings` - Generate embeddings (model: `nomic-embed-text`) -- `GET http://127.0.0.1:8000/health` - Health check +- `GET http://127.0.0.1:8787/v1/models` - List available models +- `POST http://127.0.0.1:8787/v1/chat/completions` - Chat completions (streaming and non-streaming) +- `POST http://127.0.0.1:8787/v1/embeddings` - Generate embeddings (model: `nomic-embed-text`) +- `GET http://127.0.0.1:8787/health` - Health check ## Port Override -The default port is 8000. If something else uses port 8000, override it in plugin config: +The default port is 8787. To change it: ```json { "plugins": { "entries": { "maple-openclaw-plugin": { - "config": { "port": 8200 } + "config": { "port": 9000 } } } } } ``` -If you change the port, update your `models.providers` base URL to match. - -## Authentication - -Authentication is handled automatically by the plugin via the configured API key. No per-request auth headers are needed from the agent. +If you change the port, update your `models.providers.maple.baseUrl` and `memorySearch.remote.baseUrl` to match. From 0779314b2bed68d7adc4fdd76098e9f9bed9d06b Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 15:30:47 -0600 Subject: [PATCH 15/29] fix: update stale vLLM log message to maple provider Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index 5ecb256..800b007 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -141,7 +141,7 @@ export default function register(api: PluginApi) { api.logger.info( `maple-proxy is OpenAI-compatible at http://127.0.0.1:${proxy.port}/v1 ` + - `-- configure as vLLM provider or use directly` + `-- configure as maple provider or use directly` ); } catch (err) { api.logger.error( From 547c70e56c22ffbe1d800388e1f92dc646c299e7 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 15:36:47 -0600 Subject: [PATCH 16/29] docs: note that plugin config changes require gateway restart Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/skills/maple-proxy-skill/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index d14f7c2..18353fa 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -123,3 +123,7 @@ The default port is 8787. To change it: ``` If you change the port, update your `models.providers.maple.baseUrl` and `memorySearch.remote.baseUrl` to match. + +## Configuration Changes + +Plugin config changes (port, API key, backend URL) require a full gateway restart to take effect. Model and provider config changes hot-apply without a restart. From 3ec0eaf01019a7cbf71708acc6796911bed73cde Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 16:07:04 -0600 Subject: [PATCH 17/29] fix: exclude SIGKILL from crash recovery to prevent cascading restarts SIGKILL is only sent intentionally by our own code (cleanup on failed restart, escalation on shutdown). Treating it as a crash wastes restart attempts when a respawned child fails health checks and gets cleaned up. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index 2a90b51..352756b 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -116,7 +116,7 @@ export async function startProxy( const setupCrashRecovery = (proc: ChildProcess) => { proc.on("exit", (code, signal) => { if (stopped) return; - if (signal === "SIGINT" || signal === "SIGTERM") return; + if (signal === "SIGINT" || signal === "SIGTERM" || signal === "SIGKILL") return; const crashed = (code !== null && code !== 0) || From 168bd853a363b22367252a92669d08fac3216364 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 16:53:37 -0600 Subject: [PATCH 18/29] fix: return setup instructions from maple_proxy_status when unconfigured When the API key is not set, the tool now returns step-by-step setup guidance instead of just "not running". This bridges the gap between plugin install and configuration since there is no post-install hook. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/index.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/openclaw-plugin/index.ts b/openclaw-plugin/index.ts index 800b007..16480cb 100644 --- a/openclaw-plugin/index.ts +++ b/openclaw-plugin/index.ts @@ -50,6 +50,29 @@ export default function register(api: PluginApi) { properties: {}, }, async execute() { + const pluginConfig = + api.config.plugins.entries[PLUGIN_CONFIG_KEY]?.config; + + if (!pluginConfig?.apiKey) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + running: false, + error: "maple-proxy is not configured", + setup: { + step1: 'Set your Maple API key: plugins.entries["maple-openclaw-plugin"].config.apiKey', + step2: "Add a maple provider to models.providers with baseUrl http://127.0.0.1:8787/v1 and your Maple API key", + step3: "If you have agents.defaults.models, add the maple models (e.g. maple/kimi-k2-5)", + step4: "Restart the gateway", + }, + }), + }, + ], + }; + } + if (!proxy) { return { content: [ @@ -57,7 +80,7 @@ export default function register(api: PluginApi) { type: "text", text: JSON.stringify({ running: false, - error: "maple-proxy is not running", + error: "maple-proxy is not running. The API key is configured but the service failed to start. Check gateway logs for details.", }), }, ], From 163ff0fc75598aff457a13ece2768634174eaec5 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 16:54:57 -0600 Subject: [PATCH 19/29] chore: bump to 0.1.0-beta.2 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 041dd53..104331b 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT", From 131bb1a723d255dc886d1bc99a881a4e09cf960c Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 21:53:19 -0600 Subject: [PATCH 20/29] fix: unref shutdown timers to allow clean Node.js exit Both the SIGKILL fallback timer and crash-recovery restart timer were holding the event loop open, delaying gateway shutdown by up to 6s. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index 352756b..da6518d 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -153,7 +153,7 @@ export async function startProxy( child.kill("SIGKILL"); } } - }, delay); + }, delay).unref(); } else { logger.error( `maple-proxy crashed ${MAX_RESTART_ATTEMPTS} times, giving up. ` + @@ -209,7 +209,7 @@ export async function startProxy( if (!exited) { child.kill("SIGKILL"); } - }, 3000); + }, 3000).unref(); }, }; } From 6ce7116df1be6d0bb562cfa91539fd91910b2b9a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 22:00:36 -0600 Subject: [PATCH 21/29] chore: bump to 0.1.0-beta.3 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 104331b..67fc070 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0-beta.2", + "version": "0.1.0-beta.3", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT", From 35b0bb2b8aee62ae865048d314d2f4fe19899dea Mon Sep 17 00:00:00 2001 From: Kelaode Date: Mon, 16 Feb 2026 22:34:47 -0600 Subject: [PATCH 22/29] docs: expand SKILL.md embeddings section with full setup walkthrough Rewrote the Embeddings & Memory Search section based on real-world setup experience. Key additions: - Step-by-step: enable memory-core plugin, configure memorySearch, restart, reindex, and test - Document that model must be 'nomic-embed-text' (no maple/ prefix) or the proxy returns 400 errors - Document that memory-core must be explicitly added to plugins.allow and plugins.entries (not loaded by default despite docs saying so) - Add troubleshooting section covering the 5 most common failure modes - Add verification steps (openclaw memory status --deep, CLI search) --- .../skills/maple-proxy-skill/SKILL.md | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index 18353fa..7b27284 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -78,13 +78,37 @@ Use the `maple_proxy_status` tool to check if the proxy is running, which port i ## Embeddings & Memory Search -maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-embed-text` model. You can use this for OpenClaw's memory search so that embeddings are generated inside the TEE -- no cloud embedding provider needed. +maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-embed-text` model. You can use this for OpenClaw's memory search so that embeddings are generated inside the TEE โ€” no cloud embedding provider needed. + +### 1. Enable the memory-core plugin + +The `memory_search` and `memory_get` tools are provided by OpenClaw's `memory-core` plugin. It ships as a stock plugin but must be explicitly enabled. Add it to `plugins.allow` and `plugins.entries`: + +```json +{ + "plugins": { + "allow": ["memory-core"], + "entries": { + "memory-core": { + "enabled": true + } + } + } +} +``` + +This requires a **full gateway restart** (not just SIGUSR1) since it's a plugin change. + +### 2. Configure memorySearch to use maple-proxy + +Point `memorySearch.remote` at the local maple-proxy endpoint. **Important**: the `model` field must be `nomic-embed-text` (without a `maple/` provider prefix) โ€” the proxy does not strip provider prefixes for embedding requests. ```json { "agents": { "defaults": { "memorySearch": { + "enabled": true, "provider": "openai", "model": "nomic-embed-text", "remote": { @@ -97,7 +121,48 @@ maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-emb } ``` -Use the same Maple API key here. This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings. Compatible with OpenClaw's hybrid search (BM25 + vector), session memory indexing, and embedding cache. +Use the same Maple API key you configured in the plugin config. This replaces the need for a separate OpenAI, Gemini, or Voyage API key for embeddings. + +> **Common mistake**: Setting the model to `maple/nomic-embed-text` will cause 400 errors from the proxy. Use `nomic-embed-text` (no prefix). + +### 3. Restart and reindex + +After updating the config, do a full gateway restart, then build the vector index: + +```bash +# Full restart (plugin changes require this) +systemctl restart openclaw.service + +# Index memory files and generate embeddings +openclaw memory index --verbose + +# Verify everything is working +openclaw memory status --deep +``` + +The status output should show: +- **Provider**: `openai` (this is the API format, not the actual provider) +- **Model**: `nomic-embed-text` +- **Embeddings**: `available` (not `unavailable`) +- **Vector**: `ready` + +### 4. Test with the CLI and tool + +Test from the command line first: + +```bash +openclaw memory search "your query here" +``` + +Once that works, the `memory_search` tool will also be available to the agent in chat. The agent can call `memory_search` to semantically search across `MEMORY.md` and `memory/*.md` files, with results ranked by relevance and cited with source paths. + +### Troubleshooting + +- **"memory slot plugin not found"** in logs โ†’ `memory-core` is not in `plugins.allow` or `plugins.entries`, or hasn't been restarted after adding it +- **Embeddings 400 error** โ†’ model name includes provider prefix (`maple/nomic-embed-text`), change to `nomic-embed-text` +- **Embeddings 401 error** โ†’ wrong API key, or key is a literal string like `${MAPLE_API_KEY}` instead of the actual key value +- **"Batch: disabled"** in status โ†’ embeddings failed too many times, fix the config and restart to reset the failure counter +- **Only 1/7 files indexed** โ†’ embeddings were failing, fix config, restart, then run `openclaw memory index --verbose` ## Direct API Access From 3e5198931b8636acfae7ce244c6da259e3723fa2 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 22:36:30 -0600 Subject: [PATCH 23/29] chore: bump to 0.1.0-beta.4 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 67fc070..fec159c 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0-beta.3", + "version": "0.1.0-beta.4", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT", From 60c1548872727f0c008f54faf7620e2dc313ef50 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 22:51:10 -0600 Subject: [PATCH 24/29] docs: add README.md for npm package with full setup and embeddings guide Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/README.md | 180 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 openclaw-plugin/README.md diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 0000000..91867a8 --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,180 @@ +# @opensecret/maple-openclaw-plugin + +OpenClaw plugin that automatically downloads, configures, and runs [maple-proxy](https://github.com/OpenSecretCloud/maple-proxy) as a background service. All AI inference runs inside Maple's TEE (Trusted Execution Environment) secure enclaves. + +## Install + +```bash +openclaw plugins install @opensecret/maple-openclaw-plugin +``` + +## Setup + +### 1. Configure the plugin + +Set your Maple API key in `openclaw.json`: + +```json +{ + "plugins": { + "entries": { + "maple-openclaw-plugin": { + "enabled": true, + "config": { + "apiKey": "YOUR_MAPLE_API_KEY" + } + } + } + } +} +``` + +### 2. Add the Maple provider + +Add a `maple` provider so OpenClaw can route requests to the local proxy (default port **8787**): + +```json +{ + "models": { + "providers": { + "maple": { + "baseUrl": "http://127.0.0.1:8787/v1", + "apiKey": "YOUR_MAPLE_API_KEY", + "api": "openai-completions", + "models": [ + { "id": "kimi-k2-5", "name": "Kimi K2.5 (recommended)" }, + { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" } + ] + } + } + } +} +``` + +Use the same Maple API key in both places. To discover all available models, call `GET http://127.0.0.1:8787/v1/models` after startup. + +### 3. Add models to the allowlist (if applicable) + +If you have an `agents.defaults.models` section in your config, add the maple models you want. If you don't have this section, skip this step -- all models are allowed by default. + +```json +{ + "agents": { + "defaults": { + "models": { + "maple/kimi-k2-5": {}, + "maple/llama-3.3-70b": {} + } + } + } +} +``` + +### 4. Restart the gateway + +```bash +systemctl restart openclaw.service +``` + +Plugin config changes always require a full gateway restart. Model and provider config changes hot-apply without a restart. + +## Usage + +Use maple models by prefixing with `maple/`: + +- `maple/kimi-k2-5` (recommended) +- `maple/llama-3.3-70b` + +The plugin also registers a `maple_proxy_status` tool that shows the proxy's health, port, version, and available endpoints. If the plugin isn't configured yet, the tool returns setup instructions. + +## Embeddings & Memory Search + +maple-proxy serves an OpenAI-compatible embeddings endpoint using the `nomic-embed-text` model. You can use this for OpenClaw's memory search so embeddings are generated inside the TEE -- no cloud embedding provider needed. + +### Enable the memory-core plugin + +The `memory_search` and `memory_get` tools come from OpenClaw's `memory-core` plugin. It ships as a stock plugin but **must be explicitly enabled**: + +```json +{ + "plugins": { + "allow": ["memory-core"], + "entries": { + "memory-core": { + "enabled": true + } + } + } +} +``` + +### Configure memorySearch + +> **Important**: The model field must be `nomic-embed-text` (without a `maple/` prefix). Using `maple/nomic-embed-text` will cause 400 errors. + +```json +{ + "agents": { + "defaults": { + "memorySearch": { + "enabled": true, + "provider": "openai", + "model": "nomic-embed-text", + "remote": { + "baseUrl": "http://127.0.0.1:8787/v1/", + "apiKey": "YOUR_MAPLE_API_KEY" + } + } + } + } +} +``` + +### Restart and reindex + +```bash +systemctl restart openclaw.service +openclaw memory index --verbose +openclaw memory status --deep +``` + +The status output should show **Embeddings: available** and **Vector: ready**. + +### Troubleshooting + +| Problem | Cause | Fix | +|---|---|---| +| "memory slot plugin not found" | `memory-core` not enabled | Add to `plugins.allow` and `plugins.entries`, restart | +| Embeddings 400 error | Model has provider prefix | Change `maple/nomic-embed-text` to `nomic-embed-text` | +| Embeddings 401 error | Wrong API key | Check the key is the actual value, not a placeholder | +| "Batch: disabled" in status | Too many embedding failures | Fix config, restart to reset failure counter | +| Only some files indexed | Embeddings were failing during indexing | Fix config, restart, run `openclaw memory index --verbose` | + +## Plugin Config Options + +| Option | Default | Description | +|---|---|---| +| `apiKey` | (required) | Your Maple API key | +| `port` | `8787` | Local port for the proxy | +| `backendUrl` | `https://enclave.trymaple.ai` | Maple TEE backend URL | +| `debug` | `false` | Enable debug logging | +| `version` | (latest) | Pin to a specific maple-proxy version | + +## Updating + +```bash +openclaw plugins update maple-openclaw-plugin +``` + +> **Note**: `openclaw plugins update` works for stable releases. To move between beta versions, reinstall with the full version: `openclaw plugins install @opensecret/maple-openclaw-plugin@0.1.0-beta.4` + +## Direct API Access + +- `GET http://127.0.0.1:8787/v1/models` -- List available models +- `POST http://127.0.0.1:8787/v1/chat/completions` -- Chat completions (streaming and non-streaming) +- `POST http://127.0.0.1:8787/v1/embeddings` -- Generate embeddings (model: `nomic-embed-text`) +- `GET http://127.0.0.1:8787/health` -- Health check + +## License + +MIT From 361f3ec8ec7c34e56597ba6b8ad2f9bb5cf402b7 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 23:19:52 -0600 Subject: [PATCH 25/29] docs: restructure README with recommended (agent-driven) and manual setup paths Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 91867a8..5f24617 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -2,13 +2,23 @@ OpenClaw plugin that automatically downloads, configures, and runs [maple-proxy](https://github.com/OpenSecretCloud/maple-proxy) as a background service. All AI inference runs inside Maple's TEE (Trusted Execution Environment) secure enclaves. -## Install +## Quick Start (Recommended) + +Install the plugin and let your agent handle the rest: ```bash openclaw plugins install @opensecret/maple-openclaw-plugin ``` -## Setup +Then tell your agent: + +> Install and configure maple-proxy with my API key: `YOUR_MAPLE_API_KEY` + +The plugin bundles a skill that teaches the agent how to set up the maple provider, configure models, and enable embeddings. After a gateway restart, the agent will have all the context it needs from the skill to complete the setup. If the plugin isn't configured yet, the `maple_proxy_status` tool also returns step-by-step instructions. + +## Manual Setup + +If you prefer to configure everything yourself, follow these steps after installing the plugin. ### 1. Configure the plugin From 40cbb9e7f09fe59856808e9d3c31447b21e4e153 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 16 Feb 2026 23:20:22 -0600 Subject: [PATCH 26/29] fix: capture child reference locally in crash recovery to prevent race When multiple restart attempts overlap, the shared child variable could be reassigned by a later attempt before an earlier one finishes. The stale catch block would then kill the wrong process. Now each restart captures its own spawned reference for cleanup. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/lib/process.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openclaw-plugin/lib/process.ts b/openclaw-plugin/lib/process.ts index da6518d..68b7c7a 100644 --- a/openclaw-plugin/lib/process.ts +++ b/openclaw-plugin/lib/process.ts @@ -137,11 +137,12 @@ export async function startProxy( ); setTimeout(async () => { if (stopped) return; + const spawned = spawnProxy(config, port, logger); + child = spawned; + exited = false; + trackExit(spawned); + setupCrashRecovery(spawned); try { - child = spawnProxy(config, port, logger); - exited = false; - trackExit(child); - setupCrashRecovery(child); await waitForHealth(port); logger.info(`maple-proxy restarted on http://127.0.0.1:${port}`); restartAttempts = 0; @@ -149,8 +150,8 @@ export async function startProxy( logger.error( `Failed to restart maple-proxy: ${err instanceof Error ? err.message : err}` ); - if (!child.killed) { - child.kill("SIGKILL"); + if (!spawned.killed) { + spawned.kill("SIGKILL"); } } }, delay).unref(); From 0419ed63b7f510dd5b5c0ecbc97aaa26aefe0ed3 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 17 Feb 2026 09:05:23 -0600 Subject: [PATCH 27/29] chore: release 0.1.0 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index fec159c..7a54cf4 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0-beta.4", + "version": "0.1.0", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT", From 0fb923cef903fec3e7cac7b0bc003ad2fa176278 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 17 Feb 2026 09:09:33 -0600 Subject: [PATCH 28/29] docs: add all available models to SKILL.md and README Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/README.md | 13 +++++++++++-- openclaw-plugin/skills/maple-proxy-skill/SKILL.md | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 5f24617..2e9dd43 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -53,7 +53,10 @@ Add a `maple` provider so OpenClaw can route requests to the local proxy (defaul "api": "openai-completions", "models": [ { "id": "kimi-k2-5", "name": "Kimi K2.5 (recommended)" }, - { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" } + { "id": "deepseek-r1-0528", "name": "DeepSeek R1" }, + { "id": "gpt-oss-120b", "name": "GPT-OSS 120B" }, + { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" }, + { "id": "qwen3-vl-30b", "name": "Qwen3 VL 30B" } ] } } @@ -73,7 +76,10 @@ If you have an `agents.defaults.models` section in your config, add the maple mo "defaults": { "models": { "maple/kimi-k2-5": {}, - "maple/llama-3.3-70b": {} + "maple/deepseek-r1-0528": {}, + "maple/gpt-oss-120b": {}, + "maple/llama-3.3-70b": {}, + "maple/qwen3-vl-30b": {} } } } @@ -93,7 +99,10 @@ Plugin config changes always require a full gateway restart. Model and provider Use maple models by prefixing with `maple/`: - `maple/kimi-k2-5` (recommended) +- `maple/deepseek-r1-0528` +- `maple/gpt-oss-120b` - `maple/llama-3.3-70b` +- `maple/qwen3-vl-30b` The plugin also registers a `maple_proxy_status` tool that shows the proxy's health, port, version, and available endpoints. If the plugin isn't configured yet, the tool returns setup instructions. diff --git a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md index 7b27284..24494b1 100644 --- a/openclaw-plugin/skills/maple-proxy-skill/SKILL.md +++ b/openclaw-plugin/skills/maple-proxy-skill/SKILL.md @@ -24,7 +24,10 @@ Add a `maple` provider to your `openclaw.json` with your Maple API key and the m "api": "openai-completions", "models": [ { "id": "kimi-k2-5", "name": "Kimi K2.5 (recommended)" }, - { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" } + { "id": "deepseek-r1-0528", "name": "DeepSeek R1" }, + { "id": "gpt-oss-120b", "name": "GPT-OSS 120B" }, + { "id": "llama-3.3-70b", "name": "Llama 3.3 70B" }, + { "id": "qwen3-vl-30b", "name": "Qwen3 VL 30B" } ] } } @@ -48,7 +51,10 @@ Add each model you want to use as `maple/`. Check available models via "defaults": { "models": { "maple/kimi-k2-5": {}, - "maple/llama-3.3-70b": {} + "maple/deepseek-r1-0528": {}, + "maple/gpt-oss-120b": {}, + "maple/llama-3.3-70b": {}, + "maple/qwen3-vl-30b": {} } } } @@ -64,7 +70,10 @@ Restart the OpenClaw gateway to pick up the new provider and model config. Use maple models by prefixing with `maple/`: - `maple/kimi-k2-5` (recommended) +- `maple/deepseek-r1-0528` +- `maple/gpt-oss-120b` - `maple/llama-3.3-70b` +- `maple/qwen3-vl-30b` To spawn a subagent on a Maple model: From b885e562f5bda8e5a91a15f46287317384b7ecbc Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 17 Feb 2026 09:10:01 -0600 Subject: [PATCH 29/29] chore: release 0.1.1 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- openclaw-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index 7a54cf4..334c42e 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/maple-openclaw-plugin", - "version": "0.1.0", + "version": "0.1.1", "description": "OpenClaw plugin that runs Maple TEE-backed AI models via maple-proxy", "type": "module", "license": "MIT",