Files
olp/olp-plugin/index.js
T
53afea47ca feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)
Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11
IDR (plugin consumes existing endpoints; integration docs reference plugin
+ olp CLI + olp-connect together; README index links all).

After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter
+ port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor /
D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The
v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay.

## D71 — olp-plugin/ (OpenClaw gateway plugin)

Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482
lines) as the /olp Telegram+Discord slash command, but MINUS mutations
(no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs
— all of these require SSH out of chat for security).

Plugin shape:
- olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand
- olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl
  default http://127.0.0.1:4567 (matches D60)
- olp-plugin/package.json — minimal: name/version/type:module + OpenClaw
  discovery block
- olp-plugin/README.md — install + configure + use docs; documents the
  "no mutations from chat" security stance and the dedicated-bot-key
  pattern (don't share maintainer's personal key with the bot)

Subcommand parity with olp CLI (D64-D67):
- /olp status   → GET /v0/management/status         (owner-only)
- /olp health   → GET /health                       (public-ok)
- /olp usage    → GET /v0/management/dashboard-data (owner-only)
- /olp models   → GET /v1/models                    (public-ok)
- /olp cache    → GET /cache/stats                  (owner-only)
- /olp providers → local cross-ref                  (public-ok)
- /olp chain show [<model>] → local                 (public-ok, advisory
  if no FS access — defer to ssh + olp chain show)
- /olp doctor   → informational (HTTP doctor endpoint deferred; advisory
  to ssh + olp doctor for live use)
- /olp help     → usage text

Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config
proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace
code block with status icons (🟢🟡🔴). Long responses truncated for the
4096-char message limit.

No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses
fetch + node builtins only.

## D72 — docs/integrations/*.md (6 IDE pages + index)

Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope
posture for Claude Code:

- continue.md    — config.yaml (NOT config.json); apiBase; requestOptions.headers
- cline.md       — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning
- cursor.md     ⚠️  — known base-URL fragility; only enable models OLP serves
- aider.md       — OPENAI_API_BASE env + openai/ prefix; .env support
- claude-code.md  — explicitly NOT supported per ADR 0010 § /v1/messages defer
                     rationale; recommended alternative: Cline + OLP
- openclaw.md    — install olp-plugin via CLI or symlink; configure apiKey;
                    restart gateway

Each ~60-120 lines: status / quick setup / known issues / OLP-specific
notes / test-it command. docs/integrations/README.md is the index.

## D73 — README cross-references

- New § "IDE Setup" links to docs/integrations/README.md
- New § "Telegram / Discord Usage" — install + configure + restart + use
- Quick Start mentions olp-connect <ip> as family-onboarding command
- package.json `files` field extended to include olp-plugin/ so the
  published tarball ships the plugin

## Test count

672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters /
dispatch / error paths). All 696 pass locally.

## Scope discipline

- server.mjs UNTOUCHED (plugin consumes EXISTING endpoints)
- No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport)
- No /v1/messages (out of Phase 4 per ADR 0010)
- No CHANGELOG / package.json version bump (Phase 4 close handles versioning;
  only package.json `files` extended for olp-plugin/ publication)

## Implementor flagged for reviewer

1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When
   future phase exposes /v0/management/doctor, swap advisory branch for
   real fetchJSON + fmtDoctor (already implemented + tested).
2. /olp providers + chain show have no FS access (plugin runs in OpenClaw
   gateway process); registry read via lazy-imported models-registry.json
   from repo root. For live enabled-state visibility users still need
   /olp status (owner-tier) or ssh + olp providers / olp chain show.
3. No live-server wire test in Suite 35 — existing Suites 31/32 already
   cover the integration path against the same endpoints; mock-fetch in
   Suite 35 is sufficient signal for the plugin layer.

## Authority

- ADR 0010 § Phase 4 D-day plan D71-D73 line
- OCP ocp-plugin/index.js (port reference)
- ADR 0010 § Out-of-Phase-4-scope (claude-code.md  rationale)
- 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art
  survey IDE-specific quirks for cline/cursor/continue docs)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:35:23 +10:00

438 lines
18 KiB
JavaScript

/**
* OLP Plugin — registers /olp as a native slash command in the OpenClaw gateway.
* Calls the local OLP proxy and formats the response for Telegram/Discord.
*
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
* to the OLP namespace with two structural differences:
*
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
* shared / persistent surfaces; rotating an owner key or pulling raw audit
* logs from a chat client is a security regression. Use SSH + the local
* `olp` CLI for those operations.
*
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
* OLP API key with owner_tier="owner". The plugin config carries that key;
* operators are advised to mint a dedicated bot key (NOT the maintainer's
* personal owner key) so revocation is scoped.
*
* Port resolution (in priority order):
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
* 2. OLP_PORT env (port only; localhost assumed)
* 3. Plugin config `proxyUrl`
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
*
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
* mutating operations. Mapping table is in ./README.md.
*/
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
export function mono(text) {
return "```\n" + text + "\n```";
}
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
export function bar(pct, width = 16) {
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
const filled = Math.round(p * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
/** Status icon — used in `/olp status` summary lines. */
export function statusIcon(status) {
if (status === "ok" || status === true) return "🟢";
if (status === "degraded" || status === "warn") return "🟡";
return "🔴";
}
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
export function truncateForChat(text, maxChars = 3900) {
if (text.length <= maxChars) return text;
const SUFFIX = "\n... [truncated, use SSH for full]";
// Reserve room for the suffix so the final string is <= maxChars.
const room = Math.max(0, maxChars - SUFFIX.length);
return text.slice(0, room) + SUFFIX;
}
// ── Proxy URL resolution ───────────────────────────────────────────────────
/**
* Resolve the proxy base URL. Order: OLP_PROXY_URL env → OLP_PORT env →
* plugin config `proxyUrl` → default http://127.0.0.1:4567.
*
* Exported for test injection. Pass `env` to override `process.env` and
* `config` to override the plugin config block.
*/
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
if (config.proxyUrl) return config.proxyUrl;
return "http://127.0.0.1:4567";
}
// ── HTTP helper ────────────────────────────────────────────────────────────
/**
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
*
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
*/
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
const headers = {};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const resp = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(timeoutMs),
});
if (resp.status === 401) {
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
}
if (resp.status === 403) {
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
}
if (!resp.ok) {
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
}
return resp.json();
}
// ── Subcommand formatters ──────────────────────────────────────────────────
//
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
// string. The dispatcher fetches + delegates. This split makes the formatters
// unit-testable without an HTTP mock.
export function fmtStatus(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
if (body.providers?.status && typeof body.providers.status === "object") {
for (const [name, s] of Object.entries(body.providers.status)) {
const i = statusIcon(s?.ok ? "ok" : "fail");
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
}
}
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
const c = body.stats?.cache;
if (c) {
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
out += `\nRecent errors (${body.recent_errors.length}):\n`;
for (const e of body.recent_errors.slice(0, 3)) {
const ts = (e.time || "").slice(11, 19);
const msg = String(e.message ?? "").slice(0, 60);
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
}
}
return out;
}
export function fmtHealth(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
if (body.uptime_human || body.uptimeHuman) {
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
}
if (body.providers && typeof body.providers === "object") {
out += `\nProviders:\n`;
for (const [name, s] of Object.entries(body.providers)) {
if (typeof s !== "object" || s === null) continue;
const i = statusIcon(s?.ok ? "ok" : "fail");
out += ` ${i} ${name}\n`;
}
}
return out;
}
export function fmtUsage(body) {
let out = "OLP usage (24h)\n";
out += "─────────────────────────────\n";
const w = body.window_24h ?? body.usage_24h ?? {};
if (w.requests !== undefined) {
out += `Requests: ${w.requests}\n`;
out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`;
out += `Fallbacks: ${w.fallbacks ?? "?"}\n`;
} else if (typeof body.cache_hit_24h === "number") {
// Dashboard-data shape: cache_hit_24h is a rate ∈ [0,1]
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
}
if (Array.isArray(body.quota) && body.quota.length > 0) {
out += `\nPer-provider quota:\n`;
for (const q of body.quota) {
const pct = typeof q.percent_used === "number" ? q.percent_used : null;
const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api";
out += ` ${String(q.name ?? "?").padEnd(10)}${bar0}\n`;
}
}
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
out += `\nTop fallback chains (24h):\n`;
for (const f of body.top_fallback_chains_24h.slice(0, 5)) {
out += ` ${String(f.count ?? "?").padStart(5)} ${(f.chain ?? []).join(" → ")}\n`;
}
}
return out;
}
export function fmtModels(body) {
const data = body.data ?? [];
if (data.length === 0) return "No models.";
let out = `Models (${data.length})\n`;
out += "─────────────────────────────\n";
for (const m of data) {
out += ` ${m.id}${m.owned_by ? ` (${m.owned_by})` : ""}\n`;
}
return out;
}
export function fmtCache(body) {
let out = "OLP cache\n";
out += "─────────────────────────────\n";
out += `Entries: ${body.size ?? body.entries ?? "?"}\n`;
out += `Hits: ${body.hits ?? 0}\n`;
out += `Misses: ${body.misses ?? 0}\n`;
out += `Inflight: ${body.inflightCount ?? 0}\n`;
if (typeof body.evictions === "number") {
out += `Evictions: ${body.evictions}\n`;
}
return out;
}
export function fmtProviders(registry, configEnabled) {
const providers = registry?.providers ?? {};
const names = Object.keys(providers);
let out = `OLP providers (${names.length} in registry)\n`;
out += "─────────────────────────────\n";
for (const name of names) {
const p = providers[name];
const enabled = configEnabled?.[name] === true ? "enabled " : "disabled";
const tier = p?.tier ?? "?";
const modelCount = (p?.models ?? []).length;
const candidate = p?.candidate === true ? " (candidate)" : "";
out += ` ${name.padEnd(10)} ${enabled} tier ${tier} models ${String(modelCount).padStart(2)}${candidate}\n`;
}
return out;
}
export function fmtChainShow(chains, target) {
if (!chains || Object.keys(chains).length === 0) {
return "No chains configured.";
}
if (target) {
const chain = chains[target];
if (!chain) {
return `Model "${target}" not in routing.chains.\nConfigured: ${Object.keys(chains).join(", ")}`;
}
let out = `${target}:\n`;
for (const hop of chain) {
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
return out;
}
let out = "OLP routing.chains\n";
out += "─────────────────────────────\n";
for (const [model, chain] of Object.entries(chains)) {
out += `${model}:\n`;
for (const hop of chain) {
out += ` → ${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
}
return out;
}
export function fmtDoctor(body) {
// body shape: { checks, fail_count, warn_count, ok_count, kind, summary, next_action }
let out = `OLP doctor — ${body.summary ?? "?"}\n`;
out += "─────────────────────────────\n";
for (const c of (body.checks ?? []).slice(0, 20)) {
const icon = c.status === "ok" ? "🟢" : c.status === "warn" ? "🟡" : "🔴";
out += ` ${icon} ${String(c.id ?? "?").padEnd(34)} ${String(c.message ?? "").slice(0, 60)}\n`;
}
if ((body.checks ?? []).length > 20) {
out += ` ... (${body.checks.length - 20} more — use SSH 'olp doctor' for full output)\n`;
}
out += `\nfail=${body.fail_count ?? 0} warn=${body.warn_count ?? 0} ok=${body.ok_count ?? 0} kind=${body.kind ?? "?"}\n`;
if (body.next_action?.ai_executable?.length > 0) {
out += `\nNext (AI-executable):\n`;
for (const cmd of body.next_action.ai_executable.slice(0, 5)) {
out += ` $ ${cmd}\n`;
}
}
if (body.next_action?.human_required?.length > 0) {
out += `\nNext (human-required):\n`;
for (const step of body.next_action.human_required.slice(0, 5)) {
out += ` • ${step}\n`;
}
}
return out;
}
// ── Help text ──────────────────────────────────────────────────────────────
export function cmdHelp() {
return `OLP Commands (read-only)
─────────────────────────────
/olp status Process + provider + cache snapshot
/olp health /health endpoint (public-ok)
/olp usage 24h request stats + per-provider quota
/olp models Available models
/olp cache Cache stats
/olp providers Provider registry + enabled flags
/olp chain show [model] Routing chain(s) from server config
/olp doctor Diagnostic checks + suggested next action
/olp help This message
Mutating commands (keygen / revoke / restart / logs) are NOT
available from chat by design — use SSH + the local 'olp' CLI.`;
}
// ── Dispatcher ─────────────────────────────────────────────────────────────
/**
* Pure subcommand dispatcher. Returns `{ text }` always — caller wraps in
* mono() for the chat surface.
*
* Exported for unit tests. Injects:
* - fetchFn (default global fetch)
* - proxyUrl (resolved upstream so tests can pin)
* - apiKey (from plugin config)
* - registry (models-registry.json — caller provides since this module
* ships in `olp-plugin/` and the file is a sibling concept living at
* the repo root)
* - chainsLocal (local routing.chains override — usually empty; the
* server-side /v0/management/status already exposes provider+chain
* state, but chain-show is the one local-config touch that mirrors
* `olp chain show`)
*/
export async function dispatch(rawArgs, opts) {
const {
proxyUrl,
apiKey,
registry,
chainsLocal = {},
fetchFn = fetch,
} = opts;
const raw = (rawArgs || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
switch (subcmd) {
case "status": {
const body = await fetchJSON(`${proxyUrl}/v0/management/status`, { apiKey, fetchFn });
return { text: fmtStatus(body) };
}
case "health": {
const body = await fetchJSON(`${proxyUrl}/health`, { apiKey, fetchFn });
return { text: fmtHealth(body) };
}
case "usage": {
const body = await fetchJSON(`${proxyUrl}/v0/management/dashboard-data`, { apiKey, fetchFn });
return { text: fmtUsage(body) };
}
case "models": {
const body = await fetchJSON(`${proxyUrl}/v1/models`, { apiKey, fetchFn });
return { text: fmtModels(body) };
}
case "cache": {
const body = await fetchJSON(`${proxyUrl}/cache/stats`, { apiKey, fetchFn });
return { text: fmtCache(body) };
}
case "providers": {
// models-registry.json + (optionally) the server's idea of which are
// enabled. /v0/management/status carries that and is owner-gated, but
// /v1/models lists what's exposed publicly. For the chat surface we
// use the public registry shape — config.enabled is a local-config
// concept and the plugin doesn't have filesystem access to
// ~/.olp/config.json by design.
return { text: fmtProviders(registry, {}) };
}
case "chain": {
// /olp chain show [model]
const inner = subargs.trim();
const parts = inner.split(/\s+/).filter(Boolean);
if (parts[0] !== "show") {
return { text: `Usage: /olp chain show [model]` };
}
const target = parts[1] ?? null;
return { text: fmtChainShow(chainsLocal, target) };
}
case "doctor": {
// /v0/management/doctor doesn't exist yet — D67 added doctor as a CLI
// surface only. The plugin reports that explicitly so families know
// to use SSH + `olp doctor` rather than waiting for a chat response.
return {
text: `/olp doctor is not yet wired through HTTP (planned for Phase 5+).\n` +
`Run \`olp doctor\` over SSH on the host running the OLP server\n` +
`for the full diagnostic output.`,
};
}
case "help":
case "--help":
case "-h":
case "":
return { text: cmdHelp() };
default:
return { text: `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}` };
}
} catch (err) {
return { text: `OLP error: ${err.message ?? String(err)}` };
}
}
// ── Plugin entry point (consumed by OpenClaw gateway) ──────────────────────
/**
* OpenClaw plugin entry. The gateway calls this with its `api` registration
* object; we register the `/olp` slash command and a handler that resolves
* the proxy URL + API key from plugin config + env, then delegates to
* `dispatch()`.
*
* The `registry` (models-registry.json) is read lazily inside the handler
* so that a stale plugin install doesn't bind to an old snapshot — and so
* the plugin module stays import-time-pure for tests.
*/
export default function (api) {
api.registerCommand({
name: "olp",
description: "OLP — usage, health, status, doctor, etc. (read-only)",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const cfg = ctx.config ?? {};
const apiKey = cfg.apiKey ?? process.env.OLP_API_KEY ?? null;
const proxyUrl = resolveProxyUrl({ env: process.env, config: cfg });
// Lazy load to avoid binding the import to the OpenClaw gateway's
// ESM cache (which may pre-resolve at plugin-discovery time).
let registry;
try {
// Convert file path to URL for ESM `import(...)`.
const { fileURLToPath, pathToFileURL } = await import("node:url");
const { dirname, resolve: pathResolve } = await import("node:path");
const here = dirname(fileURLToPath(import.meta.url));
const registryUrl = pathToFileURL(pathResolve(here, "..", "models-registry.json")).href;
registry = (await import(registryUrl, { with: { type: "json" } })).default;
} catch (e) {
registry = { providers: {} };
}
// Local chains config is not currently surfaced through HTTP. For
// chat-side chain-show we fall back to an empty map; operators
// wanting the live config view should use `olp chain show` over SSH.
const chainsLocal = {};
const { text } = await dispatch(ctx.args ?? "", {
proxyUrl,
apiKey,
registry,
chainsLocal,
});
return { text: mono(truncateForChat(text)) };
},
});
}