From f3006d77f7d5eea540ebd9937c80a49e87d1f8a1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 27 Mar 2026 21:49:52 -0400 Subject: [PATCH] fix: harden workspace-aware HTTP tool loading --- CHANGELOG.md | 1 + .../openclaw-tools.plugin-context.test.ts | 48 ++++++ src/agents/openclaw-tools.ts | 21 ++- src/gateway/gateway.test.ts | 144 ++++++++++++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 078929b05a5..f6dd2351a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai - Plugins/uninstall: remove owned `channels.` config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000. - Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras - Microsoft Teams/proactive DMs: prefer the freshest personal conversation reference for `user:` sends when multiple stored references exist, so replies stop targeting stale DM threads. (#54702) Thanks @gumclaw. +- Gateway/plugins: reuse the session workspace when building HTTP `/tools/invoke` tool lists and harden tool construction to infer the session agent workspace by default, so workspace plugins do not re-register on repeated HTTP tool calls. (#56101) thanks @neeravmakwana ## 2026.3.24 diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 38fe4d06c16..386f03cb493 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AnyAgentTool } from "./tools/common.js"; @@ -59,6 +60,53 @@ describe("createOpenClawTools plugin context", () => { ); }); + it("infers the default agent workspace for plugin tools when workspaceDir is omitted", () => { + const workspaceDir = path.join(process.cwd(), "tmp-main-workspace"); + createOpenClawTools({ + config: { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true }], + }, + } as never, + agentSessionKey: "main", + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + agentId: "main", + workspaceDir, + }), + }), + ); + }); + + it("infers the session agent workspace for plugin tools when workspaceDir is omitted", () => { + const supportWorkspace = path.join(process.cwd(), "tmp-support-workspace"); + createOpenClawTools({ + config: { + agents: { + defaults: { workspace: path.join(process.cwd(), "tmp-default-workspace") }, + list: [ + { id: "main", default: true }, + { id: "support", workspace: supportWorkspace }, + ], + }, + } as never, + agentSessionKey: "agent:support:main", + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + agentId: "support", + workspaceDir: supportWorkspace, + }), + }), + ); + }); + it("forwards browser session wiring to plugin tool context", () => { createOpenClawTools({ config: {} as never, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 6cf2d56a267..53b2403527c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -4,7 +4,7 @@ import { resolvePluginTools } from "../plugins/tools.js"; import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; -import { resolveSessionAgentId } from "./agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SpawnedToolContext } from "./spawned-context.js"; @@ -99,9 +99,19 @@ export function createOpenClawTools( } & SpawnedToolContext, ): AnyAgentTool[] { const resolvedConfig = options?.config ?? openClawToolsDeps.config; - const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); + const sessionAgentId = resolveSessionAgentId({ + sessionKey: options?.agentSessionKey, + config: resolvedConfig, + }); + // Fall back to the session agent workspace so plugin loading stays workspace-stable + // even when a caller forgets to thread workspaceDir explicitly. + const inferredWorkspaceDir = + options?.workspaceDir || !resolvedConfig + ? undefined + : resolveAgentWorkspaceDir(resolvedConfig, sessionAgentId); + const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir ?? inferredWorkspaceDir); const spawnWorkspaceDir = resolveWorkspaceRoot( - options?.spawnWorkspaceDir ?? options?.workspaceDir, + options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir, ); const deliveryContext = normalizeDeliveryContext({ channel: options?.agentChannel, @@ -251,10 +261,7 @@ export function createOpenClawTools( config: options?.config, workspaceDir, agentDir: options?.agentDir, - agentId: resolveSessionAgentId({ - sessionKey: options?.agentSessionKey, - config: options?.config, - }), + agentId: sessionAgentId, sessionKey: options?.agentSessionKey, sessionId: options?.sessionId, browser: { diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index ef2b5ce3e38..17b057332f4 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -28,6 +28,44 @@ function nextGatewayId(prefix: string): string { return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`; } +async function writeWorkspacePlugin(params: { + workspaceDir: string; + id: string; + body: string; +}): Promise { + const pluginDir = path.join(params.workspaceDir, ".openclaw", "extensions", params.id); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: params.id, + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await fs.writeFile(path.join(pluginDir, "index.cjs"), params.body, "utf8"); +} + +async function readCounterWithRetry(filePath: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = Number.parseInt(raw.trim(), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } catch { + // Wait briefly for gateway startup to finish plugin registration. + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`timed out waiting for counter file: ${filePath}`); +} + describe("gateway e2e", () => { beforeEach(() => { clearRuntimeConfigSnapshot(); @@ -151,6 +189,112 @@ describe("gateway e2e", () => { }, ); + it( + "does not reload workspace plugins when POST /tools/invoke rebuilds tools for the same workspace", + { timeout: GATEWAY_E2E_TIMEOUT_MS }, + async () => { + const envSnapshot = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", + ]); + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-http-tools-home-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); + delete process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; + + const token = nextGatewayId("http-tools-token"); + process.env.OPENCLAW_GATEWAY_TOKEN = token; + + const workspaceDir = path.join(tempHome, "openclaw"); + await fs.mkdir(workspaceDir, { recursive: true }); + const registerCountPath = path.join(tempHome, "workspace-plugin-register-count.txt"); + await writeWorkspacePlugin({ + workspaceDir, + id: "http-probe", + body: ` +const fs = require("node:fs"); +const counterPath = ${JSON.stringify(registerCountPath)}; +module.exports = { + id: "http-probe", + register() { + const current = fs.existsSync(counterPath) + ? Number.parseInt(fs.readFileSync(counterPath, "utf8").trim(), 10) || 0 + : 0; + fs.writeFileSync(counterPath, String(current + 1), "utf8"); + }, +}; +`.trimStart(), + }); + + const configDir = path.join(tempHome, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "openclaw.json"); + const cfg = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true, tools: { allow: ["agents_list"] } }], + }, + plugins: { + allow: ["http-probe"], + }, + gateway: { auth: { token } }, + }; + await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`); + process.env.OPENCLAW_CONFIG_PATH = configPath; + + const port = await getFreeGatewayPort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + try { + const beforeCount = await readCounterWithRetry(registerCountPath); + expect(beforeCount).toBeGreaterThan(0); + + const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + connection: "close", + }, + body: JSON.stringify({ + tool: "agents_list", + action: "json", + args: {}, + sessionKey: "main", + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + const afterCount = await readCounterWithRetry(registerCountPath); + expect(afterCount).toBe(beforeCount); + } finally { + await server.close({ reason: "http tools workspace test complete" }); + await fs.rm(tempHome, { recursive: true, force: true }); + envSnapshot.restore(); + } + }, + ); + it( "runs wizard over ws and writes auth token config", { timeout: GATEWAY_E2E_TIMEOUT_MS },