Skip to content

Commit fe4c8ad

Browse files
fix: align plugin schema with code, fix setup command, add proxy health check
1 parent e04786e commit fe4c8ad

File tree

4 files changed

+119
-29
lines changed

4 files changed

+119
-29
lines changed

openclaw.plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"id": "carapace",
33
"name": "Carapace",
44
"description": "Immutable policy boundaries for MCP tool access. Your agent's exoskeleton.",
5-
"version": "0.3.0",
5+
"version": "0.4.0",
66
"configSchema": {
77
"type": "object",
8-
"additionalProperties": false,
8+
"additionalProperties": true,
99
"properties": {
1010
"guiPort": {
1111
"type": "number",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@clawdreyhepburn/carapace",
3-
"version": "0.3.3",
3+
"version": "0.4.0",
44
"description": "Immutable policy boundaries for MCP tool access. Powered by Cedar + Cedarling WASM.",
55
"license": "Apache-2.0",
66
"type": "module",

src/index.ts

Lines changed: 112 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,46 @@ interface OpenClawPluginApi {
4141
registerGatewayMethod?(name: string, handler: (ctx: { respond: (ok: boolean, data: any) => void }) => void): void;
4242
}
4343

44+
/**
45+
* Build upstream config from either string or object format.
46+
* String format: proxy.upstream = "https://api.anthropic.com", proxy.apiKey = "sk-..."
47+
* Object format: proxy.upstream = { anthropic: { url, apiKey }, openai: { url, apiKey } }
48+
*/
49+
function buildUpstreamConfig(proxyConfig: NonNullable<PluginConfig["proxy"]>): {
50+
anthropic?: { url: string; apiKey: string };
51+
openai?: { url: string; apiKey: string };
52+
} {
53+
const upstream = proxyConfig.upstream;
54+
55+
if (!upstream) return {};
56+
57+
// String format: single upstream URL + flat apiKey
58+
if (typeof upstream === "string") {
59+
const apiKey = proxyConfig.apiKey ?? "";
60+
const url = upstream;
61+
// Guess provider from URL
62+
if (url.includes("anthropic")) {
63+
return { anthropic: { url, apiKey } };
64+
} else if (url.includes("openai")) {
65+
return { openai: { url, apiKey } };
66+
}
67+
// Default to anthropic
68+
return { anthropic: { url, apiKey } };
69+
}
70+
71+
// Object format: multi-provider
72+
return {
73+
anthropic: upstream.anthropic ? {
74+
url: upstream.anthropic.url ?? "https://api.anthropic.com",
75+
apiKey: upstream.anthropic.apiKey,
76+
} : undefined,
77+
openai: upstream.openai ? {
78+
url: upstream.openai.url ?? "https://api.openai.com",
79+
apiKey: upstream.openai.apiKey,
80+
} : undefined,
81+
};
82+
}
83+
4484
export default function register(api: OpenClawPluginApi) {
4585
const config: PluginConfig = api.pluginConfig ?? {};
4686
const logger = api.logger;
@@ -58,6 +98,9 @@ export default function register(api: OpenClawPluginApi) {
5898
logger,
5999
});
60100

101+
// --- LLM Proxy: intercept tool calls at the API level ---
102+
const proxyConfig = config.proxy;
103+
61104
const gui = new ControlGui({
62105
port: config.guiPort ?? 19820,
63106
aggregator,
@@ -66,20 +109,9 @@ export default function register(api: OpenClawPluginApi) {
66109
proxyEnabled: !!proxyConfig?.enabled,
67110
});
68111

69-
// --- LLM Proxy: intercept tool calls at the API level ---
70-
const proxyConfig = config.proxy;
71-
const proxy = proxyConfig?.enabled ? new LlmProxy({
112+
let proxy: LlmProxy | null = proxyConfig?.enabled ? new LlmProxy({
72113
port: proxyConfig.port ?? 19821,
73-
upstream: {
74-
anthropic: proxyConfig.upstream?.anthropic ? {
75-
url: proxyConfig.upstream.anthropic.url ?? "https://api.anthropic.com",
76-
apiKey: proxyConfig.upstream.anthropic.apiKey,
77-
} : undefined,
78-
openai: proxyConfig.upstream?.openai ? {
79-
url: proxyConfig.upstream.openai.url ?? "https://api.openai.com",
80-
apiKey: proxyConfig.upstream.openai.apiKey,
81-
} : undefined,
82-
},
114+
upstream: buildUpstreamConfig(proxyConfig),
83115
cedar,
84116
logger,
85117
}) : null;
@@ -131,6 +163,17 @@ export default function register(api: OpenClawPluginApi) {
131163
return { patched: toAdd, alreadyDenied };
132164
}
133165

166+
function backupConfig(): void {
167+
const { readFileSync, writeFileSync, existsSync, copyFileSync } = require("node:fs");
168+
const { join } = require("node:path");
169+
const { homedir } = require("node:os");
170+
const configPath = join(homedir(), ".openclaw", "openclaw.json");
171+
if (existsSync(configPath)) {
172+
const backupPath = configPath + ".carapace-backup";
173+
copyFileSync(configPath, backupPath);
174+
}
175+
}
176+
134177
function patchConfigProxyBaseUrl(): { patched: string[]; alreadySet: string[] } {
135178
const { readFileSync, writeFileSync, existsSync } = require("node:fs");
136179
const { join } = require("node:path");
@@ -143,28 +186,45 @@ export default function register(api: OpenClawPluginApi) {
143186
const port = config.proxy?.port ?? 19821;
144187
const proxyUrl = `http://127.0.0.1:${port}`;
145188

146-
// Figure out which providers have upstream keys configured
147-
const providers: string[] = [];
148-
if (config.proxy?.upstream?.anthropic) providers.push("anthropic");
149-
if (config.proxy?.upstream?.openai) providers.push("openai");
189+
// Figure out which providers are configured
190+
const upstreamConfig = proxyConfig ? buildUpstreamConfig(proxyConfig) : {};
191+
const providers = Object.keys(upstreamConfig).filter(
192+
(k) => upstreamConfig[k as keyof typeof upstreamConfig],
193+
);
150194

151195
const patched: string[] = [];
152196
const alreadySet: string[] = [];
153197

154198
if (!cfg.models) cfg.models = {};
199+
if (!cfg.models.mode) cfg.models.mode = "merge";
155200
if (!cfg.models.providers) cfg.models.providers = {};
156201

157202
for (const provider of providers) {
158203
if (!cfg.models.providers[provider]) cfg.models.providers[provider] = {};
204+
// Ensure models array exists (OpenClaw requires it)
205+
if (!Array.isArray(cfg.models.providers[provider].models)) {
206+
cfg.models.providers[provider].models = [];
207+
}
159208
if (cfg.models.providers[provider].baseUrl === proxyUrl) {
160209
alreadySet.push(provider);
161210
} else {
211+
// Store original baseUrl for clean revert
212+
if (cfg.models.providers[provider].baseUrl && cfg.models.providers[provider].baseUrl !== proxyUrl) {
213+
cfg.models.providers[provider]._originalBaseUrl = cfg.models.providers[provider].baseUrl;
214+
}
162215
cfg.models.providers[provider].baseUrl = proxyUrl;
163216
patched.push(provider);
164217
}
165218
}
166219

167-
if (patched.length > 0) {
220+
// Ensure plugin config is under plugins.entries.carapace.config
221+
if (!cfg.plugins) cfg.plugins = {};
222+
if (!cfg.plugins.entries) cfg.plugins.entries = {};
223+
if (!cfg.plugins.entries.carapace) cfg.plugins.entries.carapace = {};
224+
if (!cfg.plugins.entries.carapace.config) cfg.plugins.entries.carapace.config = {};
225+
226+
if (patched.length > 0 || !cfg.plugins.entries.carapace.enabled) {
227+
cfg.plugins.entries.carapace.enabled = true;
168228
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
169229
}
170230

@@ -183,10 +243,27 @@ export default function register(api: OpenClawPluginApi) {
183243

184244
if (proxy) {
185245
await proxy.start();
186-
logger.info(
187-
`🛡️ LLM Proxy active on http://127.0.0.1:${proxyConfig!.port ?? 19821} — ` +
188-
`all tool calls go through Cedar`
189-
);
246+
247+
// Health check: verify proxy is actually responding
248+
const proxyPort = proxyConfig!.port ?? 19821;
249+
try {
250+
const controller = new AbortController();
251+
const timer = setTimeout(() => controller.abort(), 3000);
252+
const healthResp = await fetch(`http://127.0.0.1:${proxyPort}/health`, { signal: controller.signal });
253+
clearTimeout(timer);
254+
if (!healthResp.ok) throw new Error(`HTTP ${healthResp.status}`);
255+
} catch (err: any) {
256+
logger.error(`❌ Proxy health check failed on port ${proxyPort}: ${err.message}. Disabling proxy.`);
257+
try { await proxy.stop(); } catch {}
258+
proxy = null;
259+
}
260+
261+
if (proxy) {
262+
logger.info(
263+
`🛡️ LLM Proxy active on http://127.0.0.1:${proxyPort} — ` +
264+
`all tool calls go through Cedar`
265+
);
266+
}
190267
} else {
191268
// Check for bypass vulnerabilities only when proxy is disabled
192269
const bypasses = checkForBypasses();
@@ -524,6 +601,8 @@ export default function register(api: OpenClawPluginApi) {
524601
.description("Configure OpenClaw to route all traffic through Carapace")
525602
.action(async () => {
526603
console.log("\n🦞 Carapace Setup\n");
604+
backupConfig();
605+
console.log(" 📦 Backed up openclaw.json → openclaw.json.carapace-backup");
527606
let anyChanges = false;
528607

529608
// 1. Deny built-in bypass tools
@@ -555,7 +634,8 @@ export default function register(api: OpenClawPluginApi) {
555634
}
556635
if (patched.length === 0 && alreadySet.length === 0) {
557636
console.log(" ⚠️ No upstream providers configured in proxy config.");
558-
console.log(" Add proxy.upstream.anthropic or proxy.upstream.openai to your plugin config.");
637+
console.log(' Set proxy.upstream to a URL string (e.g., "https://api.anthropic.com") with proxy.apiKey,');
638+
console.log(" or use the object format: proxy.upstream = { anthropic: { apiKey: '...' } }");
559639
}
560640
} else {
561641
console.log("\n LLM proxy not enabled — skipping baseUrl setup.");
@@ -609,11 +689,18 @@ export default function register(api: OpenClawPluginApi) {
609689
if (cfg.models?.providers) {
610690
for (const [name, provCfg] of Object.entries(cfg.models.providers)) {
611691
if ((provCfg as any)?.baseUrl === proxyUrl) {
612-
delete (provCfg as any).baseUrl;
692+
// Restore original baseUrl if stored
693+
if ((provCfg as any)._originalBaseUrl) {
694+
(provCfg as any).baseUrl = (provCfg as any)._originalBaseUrl;
695+
delete (provCfg as any)._originalBaseUrl;
696+
console.log(` ✅ Restored original baseUrl for ${name}`);
697+
} else {
698+
delete (provCfg as any).baseUrl;
699+
console.log(` ✅ Removed baseUrl proxy override for ${name}`);
700+
}
613701
// Clean up empty objects
614702
if (Object.keys(provCfg as any).length === 0) delete cfg.models.providers[name];
615703
changed = true;
616-
console.log(` ✅ Removed baseUrl proxy override for ${name}`);
617704
console.log(` ${name} will connect directly to its API again.`);
618705
}
619706
}

src/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ export interface PluginConfig {
2727
proxy?: {
2828
enabled?: boolean;
2929
port?: number; // default: 19821
30-
upstream?: {
30+
/** String (simple: base URL) or object (multi-provider) */
31+
upstream?: string | {
3132
anthropic?: { url?: string; apiKey: string };
3233
openai?: { url?: string; apiKey: string };
3334
};
35+
/** API key for the upstream provider (used with string upstream) */
36+
apiKey?: string;
3437
};
3538
}
3639

0 commit comments

Comments
 (0)