1 Commits

Author SHA1 Message Date
037e92b421 fix(bridge): /mcp/execute handles raw-object tool results (not just AgentToolResult)
OpenClaw plugins return tool results in one of two shapes:

  (a) AgentToolResult — { content: [{type:'text', text:'...'}] }
      used when the plugin wraps via asContent() helper. Every
      Dialectic.OpenclawPlugin tool follows this pattern.

  (b) raw JSON-able object — { ok:true, ...domain fields }
      used when the plugin returns data directly. Every
      Fabric.OpenclawPlugin tool follows this pattern
      (fabric-channel-list, fabric-guild-list, fabric-send-message,
      fabric-channel-set-purpose, etc).

The bridge's /mcp/execute handler only handled shape (a). When a
contractor agent (developer / contractor-test) called any fabric
tool through Claude Code, the bridge ran the tool successfully but
fell back to the literal string '(no result)' because
toolResult.content was undefined. Claude Code then dutifully
rendered '(no result)' as the tool result.

Reproduced on prod:
  openclaw agent --agent developer -m 'Call fabric-channel-list ...'
  → claude code session called mcp__openclaw__fabric-channel-list
  → bridge logged: mcp/execute tool=fabric-channel-list ...
  → bridge replied: { result: '(no result)' }
  → claude code rendered: ''

Fix: normalize the result in the bridge. If toolResult is null →
empty string; if it has a .content array → join the text segments
(shape a); if it's a string → use directly; else → JSON.stringify
the whole thing (shape b). Falls back to '(no result)' only when
all of those produce empty string.

Verified on prod after fix:
  agent receives real {"ok":true,"count":1,"channels":[...]}
  JSON payload (one real prod-push-test channel) in the response.
2026-05-24 09:33:12 +01:00
3 changed files with 43 additions and 59 deletions

View File

@@ -18,45 +18,6 @@ const SERVER_KEY = "_contractorAgentBridgeServer";
/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */
const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig";
/** OpenClaw replaces secret-like config values with this before exposing
* pluginConfig to plugins. */
const OPENCLAW_REDACTION_SENTINEL = "__OPENCLAW_REDACTED__";
/**
* OpenClaw redacts any secret-like key (anything containing "apiKey") before
* handing `api.pluginConfig` to the plugin, so `pluginConfig.bridgeApiKey` is
* the redaction sentinel — never the real value. The bridge server validates
* inbound requests against this key, while the OpenClaw model provider sends
* the real `models.providers.contractor-agent.apiKey`; using the redacted
* value therefore guarantees a permanent HTTP 401.
*
* Resolve the real shared secret from the raw on-disk config instead (same
* pattern `resolveAgent` already uses). If it is still missing or redacted,
* return "" so the loopback-only bridge skips auth rather than hard-locking.
*/
function resolveBridgeApiKey(fallback: string): string {
try {
const configPath = path.join(
process.env.HOME ?? "/root",
".openclaw",
"openclaw.json",
);
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
plugins?: {
entries?: Record<string, { config?: { bridgeApiKey?: string } }>;
};
};
const k = raw.plugins?.entries?.["contractor-agent"]?.config?.bridgeApiKey;
if (typeof k === "string" && k && k !== OPENCLAW_REDACTION_SENTINEL) {
return k;
}
} catch {
/* fall through to fallback handling */
}
if (fallback && fallback !== OPENCLAW_REDACTION_SENTINEL) return fallback;
return ""; // loopback-only bridge: skip auth instead of 401-locking
}
// ── Plugin entry ─────────────────────────────────────────────────────────────
export default definePluginEntry({
@@ -108,7 +69,7 @@ export default definePluginEntry({
api.on("gateway_start", () => {
const server = createBridgeServer({
port: config.bridgePort,
apiKey: resolveBridgeApiKey(config.bridgeApiKey),
apiKey: config.bridgeApiKey,
permissionMode: config.permissionMode,
resolveAgent,
logger: api.logger,

View File

@@ -596,14 +596,47 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
return;
}
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
const toolResult = await execFn(randomUUID(), toolArgs);
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<unknown> }).execute;
const toolResultRaw = await execFn(randomUUID(), toolArgs);
// Extract text content from AgentToolResult
const text = (toolResult.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
// Normalize the result shape. OpenClaw plugins return one of:
// (a) AgentToolResult — { content: [{type:'text', text:'...'}, ...] }
// used by tools that wrap via asContent() (e.g. all
// Dialectic.OpenclawPlugin tools).
// (b) raw JSON-able object — { ok:true, ...domain fields }
// used by tools that return data directly (e.g. every
// Fabric.OpenclawPlugin tool: fabric-channel-list,
// fabric-guild-list, fabric-send-message, etc).
// (c) null / undefined — fire-and-forget (rare).
//
// Pre-2026-05-24 the bridge ONLY handled shape (a); shape (b)
// silently returned "(no result)" to Claude Code (bug:
// fabric-channel-list and friends were unusable from contractor
// agents even though the tool actually ran successfully).
let text = "";
if (toolResultRaw == null) {
text = "";
} else if (
typeof toolResultRaw === "object" &&
Array.isArray((toolResultRaw as { content?: unknown }).content)
) {
// Shape (a)
const content = (toolResultRaw as { content: Array<{ type: string; text?: string }> }).content;
text = content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
} else if (typeof toolResultRaw === "string") {
text = toolResultRaw;
} else {
// Shape (b) — serialize the whole object as JSON. Claude Code
// is happy to parse JSON tool results.
try {
text = JSON.stringify(toolResultRaw);
} catch {
text = String(toolResultRaw);
}
}
sendJson(res, 200, { result: text || "(no result)" });
} catch (err) {

View File

@@ -79,11 +79,6 @@ function install() {
baseUrl: `http://127.0.0.1:${BRIDGE_PORT}/v1`,
apiKey: BRIDGE_API_KEY,
api: "openai-completions",
// The bridge wraps a full Claude/Gemini agent turn (tool use, multi-step),
// which routinely takes far longer than OpenClaw's default model-fetch
// timeout. Without a generous timeout OpenClaw aborts the request mid-turn
// and no reply is ever delivered. Preserve a user override if set.
timeoutSeconds: existingProvider.timeoutSeconds ?? 600,
models: [
{
id: "contractor-claude-bridge",
@@ -125,16 +120,11 @@ function install() {
cfg.plugins.entries[PLUGIN_ID] = cfg.plugins.entries[PLUGIN_ID] ?? {};
cfg.plugins.entries[PLUGIN_ID].enabled = true;
// Set default config — setIfMissing so user values are preserved.
// Set default config — setIfMissing so user values are preserved
const pluginCfg = cfg.plugins.entries[PLUGIN_ID].config ?? {};
setIfMissing(pluginCfg, "bridgePort", BRIDGE_PORT);
setIfMissing(pluginCfg, "bridgeApiKey", BRIDGE_API_KEY);
setIfMissing(pluginCfg, "permissionMode", "bypassPermissions");
// bridgeApiKey is the shared secret between the bridge server (this plugin)
// and the model provider written above. The provider apiKey is set
// authoritatively (= BRIDGE_API_KEY); the bridge side MUST stay in lockstep
// or every request 401s. Set it authoritatively too — never setIfMissing
// (a stale prior value would desync the pair).
pluginCfg.bridgeApiKey = BRIDGE_API_KEY;
cfg.plugins.entries[PLUGIN_ID].config = pluginCfg;
writeConfig(cfg);