feat: add OpenClaw plugin for seamless maple-proxy integration#13
feat: add OpenClaw plugin for seamless maple-proxy integration#13AnthonyRonning merged 29 commits intomasterfrom
Conversation
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>
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughThis PR introduces an OpenClaw plugin for maple-proxy that automates binary download, caching, and lifecycle management. It includes platform-specific artifact resolution, background service orchestration, health checks, a proxy status tool, build configuration updates, and comprehensive documentation. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User/Gateway
participant Plugin as Plugin Service
participant DL as Downloader
participant Platform as Platform Detect
participant GH as GitHub Releases
participant Process as Process Manager
participant Proxy as maple-proxy<br/>Process
participant Health as Health Check
User->>Plugin: start()
activate Plugin
Plugin->>Plugin: validate apiKey
Plugin->>DL: ensureBinary(logger)
activate DL
DL->>Platform: getArtifact()
activate Platform
Platform-->>DL: {name, archiveType}
deactivate Platform
DL->>Platform: resolveVersion(ttl cache)
DL->>GH: fetch latest tag
activate GH
GH-->>DL: version string
deactivate GH
DL->>GH: download artifact
DL->>GH: download & verify checksum
DL->>DL: extract (tar.gz/zip)
DL->>DL: set permissions
DL-->>Plugin: {binaryPath, version}
deactivate DL
Plugin->>Process: startProxy(config, version)
activate Process
Process->>Process: verify port available
Process->>Proxy: spawn with env vars
activate Proxy
Proxy-->>Process: process started
Process->>Health: GET /health (race)
activate Health
Health-->>Process: {status, port, ...}
deactivate Health
Process-->>Plugin: RunningProxy object
deactivate Process
Plugin-->>User: service running
deactivate Plugin
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly related PRs
Poem
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
- 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>
…sh 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>
- 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>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
openclaw-plugin/lib/downloader.ts (1)
136-138: Consider cleaning non‑vversion directories too.Line 136-138 only tracks directories starting with
"v". If a user pins"0.1.0"(nov), older non‑vfolders won’t be cleaned.♻️ Suggested tweak
- const versionDirs = entries - .filter((e) => e.startsWith("v")) + const versionDirs = entries + .filter((e) => /^v?\d+\.\d+\.\d+/.test(e)) .sort(compareVersionsDesc);
- 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>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
openclaw-plugin/lib/downloader.ts (1)
30-37: Consider adding a timeout to prevent indefinite hangs.The
fetchcall has no timeout. If GitHub is unreachable or slow, this could block indefinitely. Consider usingAbortControllerwith a reasonable timeout.♻️ Optional: Add fetch timeout
async function downloadFile(url: string, dest: string): Promise<void> { - const res = await fetch(url, { redirect: "follow" }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + try { + const res = await fetch(url, { redirect: "follow", signal: controller.signal }); + 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); + } finally { + clearTimeout(timeout); + } - 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); }openclaw-plugin/lib/process.ts (2)
130-143: Unhandled promise in setTimeout async callback.If
waitForHealththrows during restart, the error is logged but the resulting rejected promise from the async callback is silently ignored. While the error handling inside is fine, consider explicitly handling the returned promise to avoid potential unhandled rejection warnings in future Node.js versions.♻️ Optional: Explicitly void the async callback
- setTimeout(async () => { + setTimeout(() => { + void (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); + })(); + }, delay);
194-203: Signal ordering: SIGINT→SIGTERM is unconventional for daemon shutdown.The
kill()method sendsSIGINTfirst, then escalates toSIGTERMafter 3 seconds. Typically,SIGTERMis the standard "please terminate gracefully" signal, whileSIGINTis for interactive interrupts (Ctrl+C). If the proxy doesn't respond to either, consider a finalSIGKILLescalation.This likely works fine in practice, but if the maple-proxy binary follows standard signal conventions, swapping the order might be more idiomatic.
♻️ Optional: Consider SIGTERM→SIGKILL ordering
kill: () => { stopped = true; if (exited) return; - child.kill("SIGINT"); + child.kill("SIGTERM"); setTimeout(() => { if (!exited) { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } }, 3000); },
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>
- 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>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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>
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>
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>
- 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>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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)
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…etup paths Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Summary
Adds an OpenClaw plugin (
@opensecret/maple-proxy-openclaw-plugin) that automatically downloads, manages, and runs themaple-proxybinary as a background service within OpenClaw. Users install the plugin, provide an API key, and get immediate access to Maple's TEE-backed AI models -- no manual setup needed.Closes #12
What's included
openclaw-plugin/directory (new)index.ts-- Plugin entry point; registers a background service that starts/stopsmaple-proxylib/platform.ts-- OS/arch detection, maps to the correct GitHub Release artifact namelib/downloader.ts-- Downloads the binary from GitHub Releases, verifies SHA256 checksums, extracts and caches in~/.openclaw/tools/maple-proxy/lib/process.ts-- Spawnsmaple-proxyas a child process, finds free ports, waits for health checkopenclaw.plugin.json-- Plugin manifest with config schema (apiKeyrequired,port/backendUrl/debugoptional) and UI hintspackage.json-- npm package@opensecret/maple-proxy-openclaw-pluginskills/maple-proxy-skill/SKILL.md-- AgentSkills-compatible skill, gated on plugin being enabledDev tooling updates
flake.nix-- Addednodejs_22tocommonInputsso TypeScript plugin dev works in the Nix devShelljustfile-- Addedplugin-install,plugin-build,plugin-lint,plugin-test,check-all,plugin-link,plugin-pack,plugin-publishcommandsNaming convention
maple-proxyis reserved for the Rust binary. All plugin/skill names use amaple-proxy-prefix:maple-proxy-openclaw-plugin@opensecret/maple-proxy-openclaw-pluginmaple-proxy-skillUser experience
Then in
openclaw.json:{ "plugins": { "entries": { "maple-proxy-openclaw-plugin": { "enabled": true, "config": { "apiKey": "sk-..." } } } } }Restart the gateway -- the plugin downloads the binary, starts it, and the skill becomes available to the agent.
No changes to Rust source
The
src/directory is completely untouched. The existing release CI (release.yml) already builds binaries for all 4 platforms that the plugin downloads from.Summary by CodeRabbit
Release Notes
New Features
maple_proxy_statustool to monitor proxy health and system metrics.Documentation